Apply changes from remote database to sqlproj - sql-database-projects changes (#17738)

* update project from database

* update project from database

* Leftover merge update

* Slight refactor to add vscode entrypoints

* Re-adding leftover schemacompare bits that reference database project changes

* Removing unnecessary function

* Addiung GetDSP command to package.json

* tests and a race condition fix

* remove custom UUID generation code

* swapping awaits for voids on promises

* PR feedback

* PR feedback

* Hide update project command from vscode

* Swapping cross-extension commands for bound extension contract

* Re-adding schema compare radio buttons for sqlproj

* Adding refresh after project update

* Populating list of project scripts just before comparison to avoid missing script errors of project was separately edited

* Adding missing await for okay button enable check

* Correcting schema compare source when populated from a project

* Rename UpdateDataModel to be more clear

* Fix incorrectly changed type

* Added new runComparison schema compare command, hooked up to sqlproj extension

* Added progress indicator for "apply now" option

* moved string literal to constant

* Added missing await

* Setting missing "saveScmpButton" state to fix test

* Revert "Setting missing "saveScmpButton" state to fix test"

This reverts commit 55612c9def24ac9e3398f5bbd153d21d9d3ca37f.

* Removing preemptive resetWindow() call

* general cleanup

* PR feedback

* property renames

* Reverting rename; requires Tools Service change first

* Adding header to updateProject

* Adding missing header

* PR feedback

* adding missing await

* Handing race condition for UI enable

* Fixing broken okay enable case

* Fixing enum comparison wonk

Co-authored-by: Noureldine Yehia <t-nyehia@microsoft.com>
This commit is contained in:
Benjin Dubishar
2022-01-11 16:52:09 -08:00
committed by GitHub
parent 4fa2b50077
commit 292e60a767
20 changed files with 1103 additions and 53 deletions

View File

@@ -38,6 +38,10 @@
"light": "./images/light_icon.svg",
"dark": "./images/dark_icon.svg"
}
},
{
"command": "schemaCompare.runComparison",
"title": "%schemaCompare.runComparison%"
}
],
"languages": [
@@ -83,6 +87,10 @@
{
"command": "schemaCompare.start",
"when": "mssql:engineedition != 11"
},
{
"command": "schemaCompare.runComparison",
"when": "false"
}
]
}

View File

@@ -1,5 +1,6 @@
{
"displayName": "SQL Server Schema Compare",
"description": "SQL Server Schema Compare for Azure Data Studio supports comparing the schemas of databases and dacpacs.",
"schemaCompare.start": "Schema Compare"
}
"schemaCompare.start": "Schema Compare",
"schemaCompare.runComparison": "Run Schema Comparison"
}

View File

@@ -5,12 +5,13 @@
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as loc from '../localizedConstants';
import * as path from 'path';
import * as sqldbproj from 'sqldbproj';
import * as mssql from '../../../mssql';
import * as loc from '../localizedConstants';
import { SchemaCompareMainWindow } from '../schemaCompareMainWindow';
import { TelemetryReporter, TelemetryViews } from '../telemetry';
import { getEndpointName, getRootPath, exists, getAzdataApi, getSchemaCompareEndpointString } from '../utils';
import * as mssql from '../../../mssql';
const titleFontSize: number = 13;
@@ -141,8 +142,8 @@ export class SchemaCompareDialog {
this.schemaCompareMainWindow.sourceEndpointInfo = {
endpointType: mssql.SchemaCompareEndpointType.Project,
projectFilePath: this.sourceTextBox.value,
targetScripts: await this.getTargetScripts(true),
dataSchemaProvider: await this.getDsp(this.sourceTextBox.value),
targetScripts: await this.getProjectScriptFiles(this.sourceTextBox.value),
dataSchemaProvider: await this.getDatabaseSchemaProvider(this.sourceTextBox.value),
folderStructure: '',
serverDisplayName: '',
serverName: '',
@@ -190,8 +191,8 @@ export class SchemaCompareDialog {
endpointType: mssql.SchemaCompareEndpointType.Project,
projectFilePath: this.targetTextBox.value,
folderStructure: this.targetStructureDropdown!.value as string,
targetScripts: await this.getTargetScripts(false),
dataSchemaProvider: await this.getDsp(this.targetTextBox.value),
targetScripts: await this.getProjectScriptFiles(this.targetTextBox.value),
dataSchemaProvider: await this.getDatabaseSchemaProvider(this.targetTextBox.value),
serverDisplayName: '',
serverName: '',
databaseName: '',
@@ -528,10 +529,9 @@ export class SchemaCompareDialog {
let radioButtons = [this.sourceDatabaseRadioButton, this.sourceDacpacRadioButton];
// TODO: re-add once database projects changes are checked in; chicken-and-egg problem (https://github.com/microsoft/azuredatastudio/pull/17738)
// if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) {
// radioButtons.push(this.sourceProjectRadioButton);
// }
if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) {
radioButtons.push(this.sourceProjectRadioButton);
}
let flexRadioButtonsModel = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
@@ -617,10 +617,9 @@ export class SchemaCompareDialog {
let radioButtons = [targetDatabaseRadioButton, targetDacpacRadioButton];
// TODO: re-add once database projects changes are checked in; chicken-and-egg problem (https://github.com/microsoft/azuredatastudio/pull/17738)
// if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) {
// radioButtons.push(targetProjectRadioButton);
// }
if (vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId)) {
radioButtons.push(targetProjectRadioButton);
}
let flexRadioButtonsModel = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
@@ -636,10 +635,10 @@ export class SchemaCompareDialog {
private async shouldEnableOkayButton(): Promise<boolean> {
let sourcefilled = (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Dacpac && await this.existsDacpac(this.sourceTextBox.value))
|| (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Project && this.existsProjectFile(this.sourceTextBox.value))
|| (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Project && await this.existsProjectFile(this.sourceTextBox.value))
|| (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Database && !isNullOrUndefined(this.sourceDatabaseDropdown.value) && this.sourceDatabaseDropdown.values.findIndex(x => this.matchesValue(x, this.sourceDbEditable)) !== -1);
let targetfilled = (this.targetEndpointType === mssql.SchemaCompareEndpointType.Dacpac && await this.existsDacpac(this.targetTextBox.value))
|| (this.targetEndpointType === mssql.SchemaCompareEndpointType.Project && this.existsProjectFile(this.targetTextBox.value))
|| (this.targetEndpointType === mssql.SchemaCompareEndpointType.Project && await this.existsProjectFile(this.targetTextBox.value))
|| (this.targetEndpointType === mssql.SchemaCompareEndpointType.Database && !isNullOrUndefined(this.targetDatabaseDropdown.value) && this.targetDatabaseDropdown.values.findIndex(x => this.matchesValue(x, this.targetDbEditable)) !== -1);
return sourcefilled && targetfilled;
@@ -670,7 +669,7 @@ export class SchemaCompareDialog {
// check Database Schema Providers are set and valid
if (this.sourceEndpointType === mssql.SchemaCompareEndpointType.Project) {
try {
await this.getDsp(this.sourceTextBox.value);
await this.getDatabaseSchemaProvider(this.sourceTextBox.value);
} catch (err) {
this.showErrorMessage(loc.dspErrorSource);
}
@@ -678,7 +677,7 @@ export class SchemaCompareDialog {
if (this.targetEndpointType === mssql.SchemaCompareEndpointType.Project) {
try {
await this.getDsp(this.targetTextBox.value);
await this.getDatabaseSchemaProvider(this.targetTextBox.value);
} catch (err) {
this.showErrorMessage(loc.dspErrorTarget);
}
@@ -703,13 +702,20 @@ export class SchemaCompareDialog {
return !isNullOrUndefined(filename) && await exists(filename) && (filename.toLocaleLowerCase().endsWith('.sqlproj'));
}
private async getTargetScripts(source: boolean): Promise<string[]> {
const projectFilePath = source ? this.sourceTextBox.value : this.targetTextBox.value;
return await vscode.commands.executeCommand(loc.sqlDatabaseProjectsGetTargetScripts, projectFilePath);
private async getProjectScriptFiles(projectFilePath: string): Promise<string[]> {
const databaseProjectsExtension = vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId);
if (databaseProjectsExtension) {
return await (await databaseProjectsExtension.activate() as sqldbproj.IExtension).getProjectScriptFiles(projectFilePath);
}
}
private async getDsp(projectFilePath: string): Promise<string> {
return await vscode.commands.executeCommand(loc.sqlDatabaseProjectsGetDsp, projectFilePath);
private async getDatabaseSchemaProvider(projectFilePath: string): Promise<string> {
const databaseProjectsExtension = vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId);
if (databaseProjectsExtension) {
return await (await databaseProjectsExtension.activate() as sqldbproj.IExtension).getProjectDatabaseSchemaProvider(projectFilePath);
}
}
protected createSourceServerDropdown(): azdata.FormComponent {

View File

@@ -4,10 +4,12 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as mssql from '../../mssql/src/mssql';
import { SchemaCompareMainWindow } from './schemaCompareMainWindow';
export async function activate(extensionContext: vscode.ExtensionContext): Promise<void> {
vscode.commands.registerCommand('schemaCompare.start', async (sourceContext: any, targetContext: any = undefined, comparisonResult: any = undefined) => { await new SchemaCompareMainWindow(undefined, extensionContext, undefined).start(sourceContext, targetContext, comparisonResult); });
vscode.commands.registerCommand('schemaCompare.runComparison', async (source: mssql.SchemaCompareEndpointInfo | undefined, target: mssql.SchemaCompareEndpointInfo | undefined, runComparison: boolean = false, comparisonResult: mssql.SchemaCompareResult | undefined) => { await new SchemaCompareMainWindow(undefined, extensionContext, undefined).launch(source, target, runComparison, comparisonResult); });
}
export function deactivate(): void {

View File

@@ -339,7 +339,4 @@ export const applySuccess: string = localize('schemaCompare.applySuccess', "Proj
export const sqlDatabaseProjectExtensionId: string = 'microsoft.sql-database-projects';
// Commands
export const sqlDatabaseProjectsGetTargetScripts: string = 'sqlDatabaseProjects.schemaCompareGetTargetScripts';
export const sqlDatabaseProjectsGetDsp: string = 'sqlDatabaseProjects.schemaCompareGetDsp';
export const sqlDatabaseProjectsPublishChanges: string = 'sqlDatabaseProjects.schemaComparePublishProjectChanges';
export const sqlDatabaseProjectsShowProjectsView: string = 'sqlDatabaseProjects.schemaCompareShowProjectsView';

View File

@@ -7,11 +7,12 @@ import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import * as sqldbproj from 'sqldbproj';
import * as mssql from '../../mssql';
import * as loc from './localizedConstants';
import { SchemaCompareOptionsDialog } from './dialogs/schemaCompareOptionsDialog';
import { TelemetryReporter, TelemetryViews } from './telemetry';
import { getTelemetryErrorType, getEndpointName, verifyConnectionAndGetOwnerUri, getRootPath, getSchemaCompareEndpointString } from './utils';
import { getTelemetryErrorType, getEndpointName, verifyConnectionAndGetOwnerUri, getRootPath, getSchemaCompareEndpointString, getDataWorkspaceExtensionApi } from './utils';
import { SchemaCompareDialog } from './dialogs/schemaCompareDialog';
import { isNullOrUndefined } from 'util';
@@ -87,6 +88,9 @@ export class SchemaCompareMainWindow {
// 3. dacpac
// 4. project
public async start(sourceContext: any, targetContext: mssql.SchemaCompareEndpointInfo = undefined, comparisonResult: mssql.SchemaCompareResult = undefined): Promise<void> {
let source: mssql.SchemaCompareEndpointInfo;
let target: mssql.SchemaCompareEndpointInfo;
const targetIsSetAsProject: boolean = targetContext && targetContext.endpointType === mssql.SchemaCompareEndpointType.Project;
// if schema compare was launched from a db or a connection profile, set that as the source
@@ -94,7 +98,7 @@ export class SchemaCompareMainWindow {
if (targetIsSetAsProject) {
profile = sourceContext;
this.targetEndpointInfo = targetContext;
target = targetContext;
} else {
profile = sourceContext ? <azdata.IConnectionProfile>sourceContext.connectionProfile : undefined;
}
@@ -115,7 +119,7 @@ export class SchemaCompareMainWindow {
usr = loc.defaultText;
}
this.sourceEndpointInfo = {
source = {
endpointType: mssql.SchemaCompareEndpointType.Database,
serverDisplayName: `${profile.serverName} (${usr})`,
serverName: profile.serverName,
@@ -130,7 +134,7 @@ export class SchemaCompareMainWindow {
folderStructure: ''
};
} else if (sourceDacpac) {
this.sourceEndpointInfo = {
source = {
endpointType: mssql.SchemaCompareEndpointType.Dacpac,
serverDisplayName: '',
serverName: '',
@@ -144,7 +148,7 @@ export class SchemaCompareMainWindow {
folderStructure: ''
};
} else if (sourceProject) {
this.sourceEndpointInfo = {
source = {
endpointType: mssql.SchemaCompareEndpointType.Project,
packageFilePath: '',
serverDisplayName: '',
@@ -159,14 +163,38 @@ export class SchemaCompareMainWindow {
};
}
await this.launch(source, target, false, comparisonResult);
}
/**
* Primary functional entrypoint for opening the schema comparison window, and optionally running it.
* @param source
* @param target
* @param runComparison whether to immediately run the schema comparison. Requires both source and target to be specified. Cannot be true when comparisonResult is set.
* @param comparisonResult a pre-computed schema comparison result to display. Cannot be set when runComparison is true.
*/
public async launch(source: mssql.SchemaCompareEndpointInfo | undefined, target: mssql.SchemaCompareEndpointInfo | undefined, runComparison: boolean = false, comparisonResult: mssql.SchemaCompareResult | undefined): Promise<void> {
if (runComparison && comparisonResult) {
throw new Error('Cannot both pass a comparison result and request a new comparison be run.');
}
this.sourceEndpointInfo = source;
this.targetEndpointInfo = target;
await this.GetDefaultDeploymentOptions();
await Promise.all([
this.registerContent(),
this.editor.openEditor()
]);
if (targetIsSetAsProject) {
if (comparisonResult) {
await this.execute(comparisonResult);
} else if (runComparison) {
if (!source || !target) {
throw new Error('source and target must both be set when runComparison is true.');
}
await this.startCompare();
}
}
@@ -321,6 +349,18 @@ export class SchemaCompareMainWindow {
this.deploymentOptions = deploymentOptions;
}
private async populateProjectScripts(endpointInfo: mssql.SchemaCompareEndpointInfo): Promise<void> {
if (endpointInfo.endpointType !== mssql.SchemaCompareEndpointType.Project) {
return;
}
const databaseProjectsExtension = vscode.extensions.getExtension(loc.sqlDatabaseProjectExtensionId);
if (databaseProjectsExtension) {
endpointInfo.targetScripts = await (await databaseProjectsExtension.activate() as sqldbproj.IExtension).getProjectScriptFiles(endpointInfo.projectFilePath);
}
}
public async execute(comparisonResult: mssql.SchemaCompareCompletionResult = undefined) {
const service = await this.getService();
@@ -336,6 +376,8 @@ export class SchemaCompareMainWindow {
this.operationId = generateGuid();
}
await Promise.all([this.populateProjectScripts(this.sourceEndpointInfo), this.populateProjectScripts(this.targetEndpointInfo)]);
this.comparisonResult = await service.schemaCompare(this.operationId, this.sourceEndpointInfo, this.targetEndpointInfo, azdata.TaskExecutionMode.execute, this.deploymentOptions);
if (!this.comparisonResult || !this.comparisonResult.success) {
@@ -831,7 +873,8 @@ export class SchemaCompareMainWindow {
TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyStarted')
.withAdditionalProperties({
'startTime': Date.now().toString(),
'operationId': this.comparisonResult.operationId
'operationId': this.comparisonResult.operationId,
'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType)
}).send();
// disable apply and generate script buttons because the results are no longer valid after applying the changes
@@ -844,7 +887,12 @@ export class SchemaCompareMainWindow {
case mssql.SchemaCompareEndpointType.Database:
result = await service.schemaComparePublishDatabaseChanges(this.comparisonResult.operationId, this.targetEndpointInfo.serverName, this.targetEndpointInfo.databaseName, azdata.TaskExecutionMode.execute);
break;
case mssql.SchemaCompareEndpointType.Project: // Project apply needs sql-database-projects updates in (circular dependency; coming next) // TODO: re-add this and show project logic below
case mssql.SchemaCompareEndpointType.Project:
result = await vscode.commands.executeCommand(loc.sqlDatabaseProjectsPublishChanges, this.comparisonResult.operationId, this.targetEndpointInfo.projectFilePath, this.targetEndpointInfo.folderStructure);
if (!result.success) {
void vscode.window.showErrorMessage(loc.applyError);
}
break;
case mssql.SchemaCompareEndpointType.Dacpac: // Dacpac is an invalid publish target
default:
throw new Error(`Unsupported SchemaCompareEndpointType: ${getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType)}`);
@@ -854,7 +902,8 @@ export class SchemaCompareMainWindow {
TelemetryReporter.createErrorEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyFailed', undefined, getTelemetryErrorType(result?.errorMessage))
.withAdditionalProperties({
'operationId': this.comparisonResult.operationId
'operationId': this.comparisonResult.operationId,
'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType)
}).send();
vscode.window.showErrorMessage(loc.applyErrorMessage(result?.errorMessage));
@@ -868,8 +917,16 @@ export class SchemaCompareMainWindow {
TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyEnded')
.withAdditionalProperties({
'endTime': Date.now().toString(),
'operationId': this.comparisonResult.operationId
'operationId': this.comparisonResult.operationId,
'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType)
}).send();
if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Project) {
const workspaceApi = getDataWorkspaceExtensionApi();
workspaceApi.showProjectsView();
void vscode.window.showInformationMessage(loc.applySuccess);
}
}
});
}

View File

@@ -6,4 +6,6 @@
/// <reference path='../../../../src/vs/vscode.d.ts'/>
/// <reference path='../../../../src/sql/azdata.d.ts'/>
/// <reference path='../../../../src/sql/azdata.proposed.d.ts'/>
/// <reference types='@types/node'/>
/// <reference path='../../../data-workspace/src/dataworkspace.d.ts'/>
/// <reference path='../../../sql-database-projects/src/sqldbproj.d.ts'/>
/// <reference types='@types/node'/>

View File

@@ -9,6 +9,7 @@ import * as vscode from 'vscode';
import * as mssql from '../../mssql';
import * as os from 'os';
import * as loc from './localizedConstants';
import * as dataworkspace from 'dataworkspace';
import { promises as fs } from 'fs';
export interface IPackageInfo {
@@ -182,3 +183,8 @@ try {
export function getAzdataApi(): typeof azdataType | undefined {
return azdataApi;
}
export function getDataWorkspaceExtensionApi(): dataworkspace.IExtension {
const extension = vscode.extensions.getExtension(dataworkspace.extension.name)!;
return extension.exports;
}

View File

@@ -16,6 +16,8 @@
"onCommand:sqlDatabaseProjects.new",
"onCommand:sqlDatabaseProjects.open",
"onCommand:sqlDatabaseProjects.createProjectFromDatabase",
"onCommand:sqlDatabaseProjects.updateProjectFromDatabase",
"onCommand:sqlDatabaseProjects.addSqlBinding",
"onCommand:sqlDatabaseProjects.generateProjectFromOpenApiSpec",
"onCommand:sqlDatabaseProjects.addSqlBinding",
"workspaceContains:**/*.sqlproj",
@@ -145,6 +147,12 @@
"category": "%sqlDatabaseProjects.displayName%",
"icon": "images/databaseProjectToolbar.svg"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"title": "%sqlDatabaseProjects.updateProjectFromDatabase%",
"category": "%sqlDatabaseProjects.displayName%",
"icon": "images/databaseProjectToolbar.svg"
},
{
"command": "sqlDatabaseProjects.addDatabaseReference",
"title": "%sqlDatabaseProjects.addDatabaseReference%",
@@ -188,6 +196,11 @@
"when": "view == dataworkspace.views.main",
"group": "1_currentWorkspace@1"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"when": "view == dataworkspace.views.main",
"group": "1_currentWorkspace@2"
},
{
"command": "sqlDatabaseProjects.generateProjectFromOpenApiSpec",
"when": "view == dataworkspace.views.main",
@@ -254,6 +267,10 @@
{
"command": "sqlDatabaseProjects.createProjectFromDatabase"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"when": "false"
},
{
"command": "sqlDatabaseProjects.addDatabaseReference",
"when": "false"
@@ -299,6 +316,11 @@
"when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project && azdataAvailable",
"group": "1_dbProjectsFirst@3"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project && azdataAvailable",
"group": "1_dbProjectsFirst@4"
},
{
"command": "sqlDatabaseProjects.newItem",
"when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder",
@@ -389,20 +411,34 @@
{
"command": "sqlDatabaseProjects.createProjectFromDatabase",
"when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11",
"group": "export"
"group": "export@1"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11",
"group": "export@2"
}
],
"dataExplorer/context": [
{
"command": "sqlDatabaseProjects.createProjectFromDatabase",
"when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11",
"group": "export"
"group": "export@1"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"when": "nodeType =~ /^(Database|Server)$/ && connectionProvider == MSSQL && mssql:engineedition != 11",
"group": "export@2"
}
],
"dashboard/toolbar": [
{
"command": "sqlDatabaseProjects.createProjectFromDatabase",
"when": "connectionProvider == 'MSSQL' && mssql:engineedition != 11"
},
{
"command": "sqlDatabaseProjects.updateProjectFromDatabase",
"when": "connectionProvider == 'MSSQL' && mssql:engineedition != 11"
}
]
}

View File

@@ -9,6 +9,7 @@
"sqlDatabaseProjects.build": "Build",
"sqlDatabaseProjects.publish": "Publish",
"sqlDatabaseProjects.createProjectFromDatabase": "Create Project From Database",
"sqlDatabaseProjects.updateProjectFromDatabase": "Update Project From Database",
"sqlDatabaseProjects.properties": "Properties",
"sqlDatabaseProjects.schemaCompare": "Schema Compare",
"sqlDatabaseProjects.delete": "Delete",

View File

@@ -62,7 +62,9 @@ export const at = localize('at', "at");
// commands
export const revealFileInOsCommand = 'revealFileInOS';
export const schemaCompareStartCommand = 'schemaCompare.start';
export const schemaCompareRunComparisonCommand = 'schemaCompare.runComparison';
export const vscodeOpenCommand = 'vscode.open';
export const refreshDataWorkspaceCommand = 'dataworkspace.refresh';
// UI Strings
@@ -241,6 +243,7 @@ export const targetProject = localize('targetProject', "Target project");
export const createProjectSettings = localize('createProjectSettings', "Settings");
export const projectNameLabel = localize('projectNameLabel', "Name");
export const projectNamePlaceholderText = localize('projectNamePlaceholderText', "Enter project name");
export const projectLocationLabel = localize('projectLocationLabel', "Location");
export const projectLocationPlaceholderText = localize('projectLocationPlaceholderText', "Select location to create project");
export const browseButtonText = localize('browseButtonText', "Browse folder");
export const selectFolderStructure = localize('selectFolderStructure', "Select folder structure");
@@ -251,9 +254,28 @@ export const selectProjectLocation = localize('selectProjectLocation', "Select p
export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected project location '{0}' does not exist or is not a directory.", location); };
export const ProjectDirectoryAlreadyExistError = (projectName: string, location: string): string => { return localize('dataworkspace.projectDirectoryAlreadyExistError', "There is already a directory named '{0}' in the selected location: '{1}'.", projectName, location); };
// Update Project From Database dialog strings
export const updateProjectFromDatabaseDialogName = localize('updateProjectFromDatabaseDialogName', "Update project from database");
export const updateText = localize('updateText', "Update");
export const noSqlProjFile = localize('noSqlProjFile', "The selected project file does not exist");
export const noSchemaCompareExtension = localize('noSchemaCompareExtension', "The Schema Compare extension must be installed to a update a project from a database.");
export const projectToUpdatePlaceholderText = localize('projectToUpdatePlaceholderText', "Select project file");
export const updateAction = localize('updateAction', "Update action");
export const compareActionRadioButtonLabel = localize('compareActionRadiButtonLabel', "View changes in Schema Compare");
export const updateActionRadioButtonLabel = localize('updateActionRadiButtonLabel', "Apply all changes");
export const actionLabel = localize('actionLabel', "Action");
// Update project from database
export const applySuccess = localize('applySuccess', "Project was successfully updated.");
export const equalComparison = localize('equalComparison', "The project is already up to date with the database.");
export function applyError(errorMessage: string): string { return localize('applyError', "There was an error updating the project: {0}", errorMessage); }
export function updatingProjectFromDatabase(projectName: string, databaseName: string): string { return localize('updatingProjectFromDatabase', "Updating {0} from {1}...", projectName, databaseName); }
// Error messages
export function compareErrorMessage(errorMessage: string): string { return localize('schemaCompare.compareErrorMessage', "Schema Compare failed: {0}", errorMessage ? errorMessage : 'Unknown'); }
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);

View File

@@ -20,6 +20,9 @@ export namespace cssStyles {
export const createProjectFromDatabaseLabelWidth = '110px';
export const createProjectFromDatabaseTextboxWidth = '310px';
export const updateProjectFromDatabaseLabelWidth = '110px';
export const updateProjectFromDatabaseTextboxWidth = '310px';
// font-styles
export namespace fontStyle {
export const normal = 'normal';

View File

@@ -6,6 +6,7 @@
import type * as azdataType from 'azdata';
import * as vscode from 'vscode';
import * as vscodeMssql from 'vscode-mssql';
import * as mssql from '../../../mssql';
import * as templates from '../templates/templates';
import * as path from 'path';
@@ -64,6 +65,8 @@ export default class MainController implements vscode.Disposable {
vscode.commands.registerCommand('sqlDatabaseProjects.build', async (node: WorkspaceTreeItem) => { return this.projectsController.buildProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.publish', async (node: WorkspaceTreeItem) => { return this.projectsController.publishProject(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: WorkspaceTreeItem) => { return this.projectsController.schemaCompare(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.schemaComparePublishProjectChanges', async (operationId: string, projectFilePath: string, folderStructure: string): Promise<mssql.SchemaComparePublishProjectResult> => { return await this.projectsController.schemaComparePublishProjectChanges(operationId, projectFilePath, folderStructure); });
vscode.commands.registerCommand('sqlDatabaseProjects.updateProjectFromDatabase', async (node: azdataType.IConnectionProfile | vscodeMssql.ITreeNodeInfo | WorkspaceTreeItem) => { await this.projectsController.updateProjectFromDatabase(node); });
vscode.commands.registerCommand('sqlDatabaseProjects.createProjectFromDatabase', async (context: azdataType.IConnectionProfile | vscodeMssql.ITreeNodeInfo | undefined) => { return this.projectsController.createProjectFromDatabase(context); });
vscode.commands.registerCommand('sqlDatabaseProjects.generateProjectFromOpenApiSpec', async (options?: GenerateProjectFromOpenApiSpecOptions) => { return this.projectsController.generateProjectFromOpenApiSpec(options); });

View File

@@ -32,6 +32,7 @@ import { AddDatabaseReferenceDialog } from '../dialogs/addDatabaseReferenceDialo
import { ISystemDatabaseReferenceSettings, IDacpacReferenceSettings, IProjectReferenceSettings } from '../models/IDatabaseReferenceSettings';
import { DatabaseReferenceTreeItem } from '../models/tree/databaseReferencesTreeItem';
import { CreateProjectFromDatabaseDialog } from '../dialogs/createProjectFromDatabaseDialog';
import { UpdateProjectFromDatabaseDialog } from '../dialogs/updateProjectFromDatabaseDialog';
import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/telemetry';
import { IconPathHelper } from '../common/iconHelper';
import { DashboardData, PublishData, Status } from '../models/dashboardData/dashboardData';
@@ -43,7 +44,8 @@ import { AutorestHelper } from '../tools/autorestHelper';
import { createNewProjectFromDatabaseWithQuickpick } from '../dialogs/createProjectFromDatabaseQuickpick';
import { addDatabaseReferenceQuickpick } from '../dialogs/addDatabaseReferenceQuickpick';
import { IDeployProfile } from '../models/deploy/deployProfile';
import { FileProjectEntry, IDatabaseReferenceProjectEntry, SqlProjectReferenceProjectEntry } from '../models/projectEntry';
import { EntryType, FileProjectEntry, IDatabaseReferenceProjectEntry, SqlProjectReferenceProjectEntry } from '../models/projectEntry';
import { UpdateProjectAction, UpdateProjectDataModel } from '../models/api/updateProject';
const maxTableLength = 10;
@@ -453,23 +455,28 @@ export class ProjectsController {
return result;
}
public async schemaCompare(treeNode: dataworkspace.WorkspaceTreeItem): Promise<void> {
public async schemaCompare(source: dataworkspace.WorkspaceTreeItem | azdataType.IConnectionProfile, targetParam: any = undefined): Promise<void> {
try {
// check if schema compare extension is installed
if (vscode.extensions.getExtension(constants.schemaCompareExtensionId)) {
// build project
const dacpacPath = await this.buildProject(treeNode);
let sourceParam;
// check that dacpac exists
if (await utils.exists(dacpacPath)) {
TelemetryReporter.sendActionEvent(TelemetryViews.ProjectController, TelemetryActions.projectSchemaCompareCommandInvoked);
await vscode.commands.executeCommand(constants.schemaCompareStartCommand, dacpacPath);
if (source as dataworkspace.WorkspaceTreeItem) {
sourceParam = this.getProjectFromContext(source as dataworkspace.WorkspaceTreeItem).projectFilePath;
} else {
sourceParam = source as azdataType.IConnectionProfile;
}
try {
TelemetryReporter.sendActionEvent(TelemetryViews.ProjectController, TelemetryActions.projectSchemaCompareCommandInvoked);
await vscode.commands.executeCommand(constants.schemaCompareStartCommand, sourceParam, targetParam, undefined);
} catch (e) {
throw new Error(constants.buildFailedCannotStartSchemaCompare);
}
} else {
throw new Error(constants.schemaCompareNotInstalled);
}
} catch (err) {
const props: Record<string, string> = {};
const message = utils.getErrorMessage(err);
@@ -486,6 +493,67 @@ export class ProjectsController {
}
}
public async getProjectScriptFiles(projectFilePath: string): Promise<string[]> {
const project = await Project.openProject(projectFilePath);
return project.files
.filter(f => f.fsUri.fsPath.endsWith(constants.sqlFileExtension))
.map(f => f.fsUri.fsPath);
}
public async getProjectDatabaseSchemaProvider(projectFilePath: string): Promise<string> {
const project = await Project.openProject(projectFilePath);
return project.getProjectTargetVersion();
}
public async schemaComparePublishProjectChanges(operationId: string, projectFilePath: string, folderStructure: string): Promise<mssql.SchemaComparePublishProjectResult> {
const ext = vscode.extensions.getExtension(mssql.extension.name)!;
const service = (await ext.activate() as mssql.IExtension).schemaCompare;
const projectPath = path.dirname(projectFilePath);
let fs: mssql.ExtractTarget;
switch (folderStructure) {
case constants.file:
fs = mssql.ExtractTarget.file;
break;
case constants.flat:
fs = mssql.ExtractTarget.flat;
break;
case constants.objectType:
fs = mssql.ExtractTarget.objectType;
break;
case constants.schema:
fs = mssql.ExtractTarget.schema;
break;
case constants.schemaObjectType:
default:
fs = mssql.ExtractTarget.schemaObjectType;
break;
}
const result: mssql.SchemaComparePublishProjectResult = await service.schemaComparePublishProjectChanges(operationId, projectPath, fs, utils.getAzdataApi()!.TaskExecutionMode.execute);
const project = await Project.openProject(projectFilePath);
let toAdd: vscode.Uri[] = [];
result.addedFiles.forEach((f: any) => toAdd.push(vscode.Uri.file(f)));
await project.addToProject(toAdd);
let toRemove: vscode.Uri[] = [];
result.deletedFiles.forEach((f: any) => toRemove.push(vscode.Uri.file(f)));
let toRemoveEntries: FileProjectEntry[] = [];
toRemove.forEach(f => toRemoveEntries.push(new FileProjectEntry(f, f.path.replace(projectPath + '\\', ''), EntryType.File)));
toRemoveEntries.forEach(async f => await project.exclude(f));
await this.buildProject(project);
return result;
}
public async addFolderPrompt(treeNode: dataworkspace.WorkspaceTreeItem): Promise<void> {
const project = this.getProjectFromContext(treeNode);
const relativePathToParent = this.getRelativePath(treeNode.element);
@@ -778,9 +846,6 @@ export class ProjectsController {
}
return undefined;
}
}
/**
@@ -1246,6 +1311,118 @@ export class ProjectsController {
// TODO: Check for success; throw error
}
/**
* Display dialog for user to configure existing SQL Project with the changes/differences from a database
*/
public async updateProjectFromDatabase(context: azdataType.IConnectionProfile | mssqlVscode.ITreeNodeInfo | dataworkspace.WorkspaceTreeItem): Promise<UpdateProjectFromDatabaseDialog> {
let connection: azdataType.IConnectionProfile | mssqlVscode.IConnectionInfo | undefined;
let project: Project | undefined;
try {
if ('connectionProfile' in context) {
connection = this.getConnectionProfileFromContext(context as azdataType.IConnectionProfile | mssqlVscode.ITreeNodeInfo);
}
} catch { }
try {
if ('treeDataProvider' in context) {
project = this.getProjectFromContext(context as dataworkspace.WorkspaceTreeItem);
}
} catch { }
const updateProjectFromDatabaseDialog = this.getUpdateProjectFromDatabaseDialog(connection, project);
updateProjectFromDatabaseDialog.updateProjectFromDatabaseCallback = async (model) => await this.updateProjectFromDatabaseCallback(model);
await updateProjectFromDatabaseDialog.openDialog();
return updateProjectFromDatabaseDialog;
}
public getUpdateProjectFromDatabaseDialog(connection: azdataType.IConnectionProfile | mssqlVscode.IConnectionInfo | undefined, project: Project | undefined): UpdateProjectFromDatabaseDialog {
return new UpdateProjectFromDatabaseDialog(connection, project);
}
public async updateProjectFromDatabaseCallback(model: UpdateProjectDataModel) {
try {
await this.updateProjectFromDatabaseApiCall(model);
} catch (err) {
void vscode.window.showErrorMessage(utils.getErrorMessage(err));
}
}
/**
* Uses the DacFx service to update an existing SQL Project with the changes/differences from a database
*/
public async updateProjectFromDatabaseApiCall(model: UpdateProjectDataModel): Promise<void> {
if (model.action === UpdateProjectAction.Compare) {
await vscode.commands.executeCommand(constants.schemaCompareRunComparisonCommand, model.sourceEndpointInfo, model.targetEndpointInfo, true, undefined);
} else if (model.action === UpdateProjectAction.Update) {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: constants.updatingProjectFromDatabase(path.basename(model.targetEndpointInfo.projectFilePath), model.sourceEndpointInfo.databaseName),
cancellable: false
}, async (_progress, _token) => {
return this.schemaCompareAndUpdateProject(model.sourceEndpointInfo, model.targetEndpointInfo);
});
void vscode.commands.executeCommand(constants.refreshDataWorkspaceCommand);
utils.getDataWorkspaceExtensionApi().showProjectsView();
} else {
throw new Error(`Unknown UpdateProjectAction: ${model.action}`);
}
return;
}
private async schemaCompareAndUpdateProject(source: mssql.SchemaCompareEndpointInfo, target: mssql.SchemaCompareEndpointInfo): Promise<void> {
// Run schema comparison
const ext = vscode.extensions.getExtension(mssql.extension.name)!;
const service = (await ext.activate() as mssql.IExtension).schemaCompare;
const deploymentOptions = await service.schemaCompareGetDefaultOptions();
const operationId = UUID.generateUuid();
target.targetScripts = await this.getProjectScriptFiles(target.projectFilePath);
target.dataSchemaProvider = await this.getProjectDatabaseSchemaProvider(target.projectFilePath);
TelemetryReporter.sendActionEvent(TelemetryViews.ProjectController, 'SchemaComparisonStarted');
// Perform schema comparison. Results are cached in SqlToolsService under the operationId
const comparisonResult: mssql.SchemaCompareResult = await service.schemaCompare(
operationId, source, target, utils.getAzdataApi()!.TaskExecutionMode.execute, deploymentOptions.defaultDeploymentOptions
);
if (!comparisonResult || !comparisonResult.success) {
TelemetryReporter.createErrorEvent(TelemetryViews.ProjectController, 'SchemaComparisonFailed')
.withAdditionalProperties({
operationId: comparisonResult.operationId
}).send();
await vscode.window.showErrorMessage(constants.compareErrorMessage(comparisonResult?.errorMessage));
return;
}
TelemetryReporter.createActionEvent(TelemetryViews.ProjectController, 'SchemaComparisonFinished')
.withAdditionalProperties({
'endTime': Date.now().toString(),
'operationId': comparisonResult.operationId
}).send();
if (comparisonResult.areEqual) {
void vscode.window.showInformationMessage(constants.equalComparison);
return;
}
// Publish the changes (retrieved from the cache by operationId)
const publishResult = await this.schemaComparePublishProjectChanges(operationId, target.projectFilePath, target.folderStructure);
if (publishResult.success) {
void vscode.window.showInformationMessage(constants.applySuccess);
} else {
void vscode.window.showErrorMessage(constants.applyError(publishResult.errorMessage));
}
}
/**
* Generate a flat list of all files and folder under a folder.
*/

View File

@@ -0,0 +1,610 @@
/*---------------------------------------------------------------------------------------------
* 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 mssql from '../../../mssql';
import * as azdata from 'azdata';
import * as constants from '../common/constants';
import * as newProjectTool from '../tools/newProjectTool';
import type * as mssqlVscode from 'vscode-mssql';
import { Deferred } from '../common/promise';
import { Project } from '../models/project';
import { cssStyles } from '../common/uiConstants';
import { IconPathHelper } from '../common/iconHelper';
import { UpdateProjectDataModel, UpdateProjectAction } from '../models/api/updateProject';
import { exists, getAzdataApi, getDataWorkspaceExtensionApi } from '../common/utils';
import * as path from 'path';
export class UpdateProjectFromDatabaseDialog {
public dialog: azdata.window.Dialog;
public serverDropdown: azdata.DropDownComponent | undefined;
public databaseDropdown: azdata.DropDownComponent | undefined;
public projectFileTextBox: azdata.InputBoxComponent | undefined;
public compareActionRadioButton: azdata.RadioButtonComponent | undefined;
private updateProjectFromDatabaseTab: azdata.window.DialogTab;
private connectionButton: azdata.ButtonComponent | undefined;
private folderStructureDropDown: azdata.DropDownComponent | undefined;
private updateActionRadioButton: azdata.RadioButtonComponent | undefined;
private formBuilder: azdata.FormBuilder | undefined;
private connectionId: string | undefined;
private profile: azdata.IConnectionProfile | undefined;
public action: UpdateProjectAction | undefined;
private toDispose: vscode.Disposable[] = [];
private initDialogPromise: Deferred = new Deferred();
public populatedInputsPromise: Deferred = new Deferred();
public updateProjectFromDatabaseCallback: ((model: UpdateProjectDataModel) => any) | undefined;
constructor(connection: azdata.IConnectionProfile | mssqlVscode.IConnectionInfo | undefined, private project: Project | undefined) {
if (connection && 'connectionName' in connection) {
this.profile = connection;
}
// need to set profile when database is updated as well as here
// see what schemaCompare is doing!
this.dialog = getAzdataApi()!.window.createModelViewDialog(constants.updateProjectFromDatabaseDialogName, 'updateProjectFromDatabaseDialog');
this.updateProjectFromDatabaseTab = getAzdataApi()!.window.createTab(constants.updateProjectFromDatabaseDialogName);
this.dialog.registerCloseValidator(async () => {
return this.validate();
});
this.toDispose.push(this.dialog.onClosed(_ => this.initDialogPromise.resolve()));
}
public async openDialog(): Promise<void> {
let connection = await azdata.connection.getCurrentConnection();
if (connection) {
this.connectionId = connection.connectionId;
}
this.initializeDialog();
this.dialog.okButton.label = constants.updateText;
this.dialog.okButton.enabled = false;
this.toDispose.push(this.dialog.okButton.onClick(async () => await this.handleUpdateButtonClick()));
this.dialog.cancelButton.label = constants.cancelButtonText;
getAzdataApi()!.window.openDialog(this.dialog);
await this.initDialogPromise;
this.tryEnableUpdateButton();
}
private dispose(): void {
this.toDispose.forEach(disposable => disposable.dispose());
}
private initializeDialog(): void {
this.initializeUpdateProjectFromDatabaseTab();
this.dialog.content = [this.updateProjectFromDatabaseTab];
}
private initializeUpdateProjectFromDatabaseTab(): void {
this.updateProjectFromDatabaseTab.registerContent(async view => {
const connectionRow = this.createServerRow(view);
const databaseRow = this.createDatabaseRow(view);
await this.populateServerDropdown();
const sourceDatabaseFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component();
sourceDatabaseFormSection.addItems([connectionRow, databaseRow]);
const projectLocationRow = this.createProjectLocationRow(view);
const folderStructureRow = this.createFolderStructureRow(view);
const targetProjectFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component();
targetProjectFormSection.addItems([projectLocationRow, folderStructureRow]);
const actionRow = await this.createActionRow(view);
const actionFormSection = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component();
actionFormSection.addItems([actionRow]);
this.formBuilder = <azdata.FormBuilder>view.modelBuilder.formContainer()
.withFormItems([
{
title: constants.sourceDatabase,
components: [
{
component: sourceDatabaseFormSection,
}
]
},
{
title: constants.targetProject,
components: [
{
component: targetProjectFormSection,
}
]
},
{
title: constants.updateAction,
components: [
{
component: actionFormSection,
}
]
}
], {
horizontal: false,
titleFontSize: cssStyles.titleFontSize
})
.withLayout({
width: '100%',
padding: '10px 10px 0 20px'
});
let formModel = this.formBuilder.component();
await view.initializeModel(formModel);
await this.connectionButton?.focus();
this.initDialogPromise.resolve();
});
}
private createServerRow(view: azdata.ModelView): azdata.FlexContainer {
this.createServerComponent(view);
const serverLabel = view.modelBuilder.text().withProps({
value: constants.server,
requiredIndicator: true,
width: cssStyles.updateProjectFromDatabaseLabelWidth
}).component();
const connectionRow = view.modelBuilder.flexContainer().withItems([serverLabel, this.serverDropdown!], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-5px', 'margin-top': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component();
connectionRow.addItem(this.connectionButton!, { CSSStyles: { 'margin-right': '0px', 'margin-bottom': '-5px', 'margin-top': '-10px' } });
return connectionRow;
}
private createDatabaseRow(view: azdata.ModelView): azdata.FlexContainer {
this.createDatabaseComponent(view);
const databaseLabel = view.modelBuilder.text().withProps({
value: constants.databaseNameLabel,
requiredIndicator: true,
width: cssStyles.updateProjectFromDatabaseLabelWidth
}).component();
const databaseRow = view.modelBuilder.flexContainer().withItems([databaseLabel, <azdata.DropDownComponent>this.databaseDropdown!], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component();
return databaseRow;
}
private createServerComponent(view: azdata.ModelView) {
this.serverDropdown = view.modelBuilder.dropDown().withProps({
editable: true,
fireOnTextChange: true,
width: cssStyles.updateProjectFromDatabaseTextboxWidth
}).component();
this.createConnectionButton(view);
this.serverDropdown.onValueChanged(() => {
this.tryEnableUpdateButton();
});
}
private createDatabaseComponent(view: azdata.ModelView) {
this.databaseDropdown = view.modelBuilder.dropDown().withProps({
editable: true,
fireOnTextChange: true,
width: cssStyles.updateProjectFromDatabaseTextboxWidth
}).component();
this.databaseDropdown.onValueChanged(() => {
this.tryEnableUpdateButton();
});
}
private async populateServerDropdown() {
this.serverDropdown!.loading = true;
const values = await this.getServerValues();
if (values && values.length > 0) {
await this.serverDropdown!.updateProperties({
values: values,
value: values[0]
});
}
this.serverDropdown!.loading = false;
if (this.serverDropdown!.value) {
await this.populateDatabaseDropdown();
}
this.tryEnableUpdateButton();
this.populatedInputsPromise.resolve();
}
protected async populateDatabaseDropdown() {
const connectionProfile = (this.serverDropdown!.value as ConnectionDropdownValue).connection;
this.databaseDropdown!.loading = true;
await this.databaseDropdown!.updateProperties({
values: [],
value: undefined
});
let values = [];
try {
values = await this.getDatabaseValues(connectionProfile.connectionId);
} catch (e) {
// if the user doesn't have access to master, just set the database of the connection profile
values = [connectionProfile.databaseName];
console.warn(e);
}
if (values && values.length > 0) {
await this.databaseDropdown!.updateProperties({
values: values,
value: values[0],
});
}
this.databaseDropdown!.loading = false;
}
private async getServerValues() {
let cons = await azdata.connection.getConnections(/* activeConnectionsOnly */ true);
// This user has no active connections
if (!cons || cons.length === 0) {
return undefined;
}
// Update connection icon to "connected" state
this.connectionButton!.iconPath = IconPathHelper.connect;
// reverse list so that most recent connections are first
cons.reverse();
let count = -1;
let idx = -1;
let values = cons.map(c => {
count++;
let usr = c.options.user;
if (!usr) {
usr = constants.defaultUser;
}
let srv = c.options.server;
let finalName = `${srv} (${usr})`;
if (c.options.connectionName) {
finalName = c.options.connectionName;
}
if (c.connectionId === this.connectionId) {
idx = count;
}
return {
connection: c,
displayName: finalName,
name: srv,
};
});
// move server of current connection to the top of the list so it is the default
if (idx >= 1) {
let tmp = values[0];
values[0] = values[idx];
values[idx] = tmp;
}
values = values.reduce((uniqueValues: { connection: azdata.connection.ConnectionProfile, displayName: string, name: string }[], conn) => {
let exists = uniqueValues.find(x => x.displayName === conn.displayName);
if (!exists) {
uniqueValues.push(conn);
}
return uniqueValues;
}, []);
return values;
}
protected async getDatabaseValues(connectionId: string) {
let idx = -1;
let count = -1;
let values = (await azdata.connection.listDatabases(connectionId)).sort((a, b) => a.localeCompare(b)).map(db => {
count++;
// put currently selected db at the top of the dropdown if there is one
if (this.profile && this.profile.databaseName && this.profile.databaseName === db) {
idx = count;
}
return db;
});
if (idx >= 0) {
let tmp = values[0];
values[0] = values[idx];
values[idx] = tmp;
}
return values;
}
private createConnectionButton(view: azdata.ModelView) {
this.connectionButton = view.modelBuilder.button().withProps({
ariaLabel: constants.selectConnection,
iconPath: IconPathHelper.selectConnection,
height: '20px',
width: '20px'
}).component();
this.connectionButton.onDidClick(async () => {
await this.connectionButtonClick();
this.connectionButton!.iconPath = IconPathHelper.connect;
});
}
private async connectionButtonClick() {
let connection = await azdata.connection.openConnectionDialog();
if (connection) {
this.connectionId = connection.connectionId;
await this.populateServerDropdown();
}
}
private createProjectLocationRow(view: azdata.ModelView): azdata.FlexContainer {
const browseFolderButton: azdata.Component = this.createBrowseFileButton(view);
const value = this.project ? this.project.projectFilePath : '';
this.projectFileTextBox = view.modelBuilder.inputBox().withProps({
value: value,
ariaLabel: constants.projectLocationLabel,
placeHolder: constants.projectToUpdatePlaceholderText,
width: cssStyles.updateProjectFromDatabaseTextboxWidth
}).component();
this.projectFileTextBox.onTextChanged(async () => {
await this.projectFileTextBox!.updateProperty('title', this.projectFileTextBox!.value);
this.tryEnableUpdateButton();
});
const projectLocationLabel = view.modelBuilder.text().withProps({
value: constants.projectLocationLabel,
requiredIndicator: true,
width: cssStyles.updateProjectFromDatabaseLabelWidth
}).component();
const projectLocationRow = view.modelBuilder.flexContainer().withItems([projectLocationLabel, this.projectFileTextBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-5px', 'margin-top': '-10px' } }).component();
projectLocationRow.addItem(browseFolderButton, { CSSStyles: { 'margin-right': '0px', 'margin-bottom': '-5px', 'margin-top': '-10px' } });
return projectLocationRow;
}
private createBrowseFileButton(view: azdata.ModelView): azdata.ButtonComponent {
const browseFolderButton = view.modelBuilder.button().withProps({
ariaLabel: constants.browseButtonText,
iconPath: IconPathHelper.folder_blue,
height: '18px',
width: '18px'
}).component();
browseFolderButton.onDidClick(async () => {
let fileUris = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: constants.selectString,
defaultUri: newProjectTool.defaultProjectSaveLocation(),
filters: {
'Files': ['sqlproj']
}
});
if (!fileUris || fileUris.length === 0) {
return;
}
this.projectFileTextBox!.value = fileUris[0].fsPath;
await this.projectFileTextBox!.updateProperty('title', fileUris[0].fsPath);
});
return browseFolderButton;
}
private createFolderStructureRow(view: azdata.ModelView): azdata.FlexContainer {
this.folderStructureDropDown = view.modelBuilder.dropDown().withProps({
values: [constants.file, constants.flat, constants.objectType, constants.schema, constants.schemaObjectType],
value: constants.schemaObjectType,
ariaLabel: constants.folderStructureLabel,
required: true,
width: cssStyles.updateProjectFromDatabaseTextboxWidth
}).component();
this.folderStructureDropDown.onValueChanged(() => {
this.tryEnableUpdateButton();
});
const folderStructureLabel = view.modelBuilder.text().withProps({
value: constants.folderStructureLabel,
requiredIndicator: true,
width: cssStyles.createProjectFromDatabaseLabelWidth
}).component();
const folderStructureRow = view.modelBuilder.flexContainer().withItems([folderStructureLabel, <azdata.DropDownComponent>this.folderStructureDropDown], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component();
return folderStructureRow;
}
private async createActionRow(view: azdata.ModelView): Promise<azdata.FlexContainer> {
this.compareActionRadioButton = view.modelBuilder.radioButton().withProps({
name: 'action',
label: constants.compareActionRadioButtonLabel,
checked: true
}).component();
this.updateActionRadioButton = view.modelBuilder.radioButton().withProps({
name: 'action',
label: constants.updateActionRadioButtonLabel
}).component();
await this.compareActionRadioButton.updateProperties({ checked: true });
this.action = UpdateProjectAction.Compare;
this.compareActionRadioButton.onDidClick(async () => {
this.action = UpdateProjectAction.Compare;
this.tryEnableUpdateButton();
});
this.updateActionRadioButton.onDidClick(async () => {
this.action = UpdateProjectAction.Update;
this.tryEnableUpdateButton();
});
let radioButtons = view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.withItems([this.compareActionRadioButton, this.updateActionRadioButton])
.withProps({ ariaRole: 'radiogroup' })
.component();
const actionLabel = view.modelBuilder.text().withProps({
value: constants.actionLabel,
requiredIndicator: true,
width: cssStyles.updateProjectFromDatabaseLabelWidth
}).component();
const actionRow = view.modelBuilder.flexContainer().withItems([actionLabel, <azdata.FlexContainer>radioButtons], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-bottom': '-10px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component();
return actionRow;
}
// only enable Update button if all fields are filled
public tryEnableUpdateButton(): void {
if (this.serverDropdown?.value
&& this.databaseDropdown?.value
&& this.projectFileTextBox?.value
&& this.folderStructureDropDown?.value
&& this.action !== undefined) {
this.dialog.okButton.enabled = true;
} else {
this.dialog.okButton.enabled = false;
}
}
public async handleUpdateButtonClick(): Promise<void> {
const serverDropdownValue = this.serverDropdown!.value! as azdata.CategoryValue as ConnectionDropdownValue;
const ownerUri = await azdata.connection.getUriForConnection(serverDropdownValue.connection.connectionId);
let connection = (await azdata.connection.getConnections(true)).filter(con => con.connectionId === serverDropdownValue.connection.connectionId)[0];
connection.databaseName = this.databaseDropdown!.value! as string;
const credentials = await azdata.connection.getCredentials(connection.connectionId);
if (credentials.hasOwnProperty('password')) {
connection.password = connection.options.password = credentials.password;
}
const connectionDetails: azdata.IConnectionProfile = {
id: connection.connectionId,
userName: connection.userName,
password: connection.password,
serverName: connection.serverName,
databaseName: connection.databaseName,
connectionName: connection.connectionName,
providerName: connection.providerId,
groupId: connection.groupId,
groupFullName: connection.groupFullName,
authenticationType: connection.authenticationType,
savePassword: connection.savePassword,
saveProfile: connection.saveProfile,
options: connection.options,
};
const sourceEndpointInfo: mssql.SchemaCompareEndpointInfo = {
endpointType: mssql.SchemaCompareEndpointType.Database,
databaseName: this.databaseDropdown!.value! as string,
serverDisplayName: serverDropdownValue.displayName,
serverName: serverDropdownValue.name!,
connectionDetails: connectionDetails,
ownerUri: ownerUri,
projectFilePath: '',
folderStructure: '',
targetScripts: [],
dataSchemaProvider: '',
packageFilePath: '',
connectionName: serverDropdownValue.connection.options.connectionName
};
const targetEndpointInfo: mssql.SchemaCompareEndpointInfo = {
endpointType: mssql.SchemaCompareEndpointType.Project,
projectFilePath: this.projectFileTextBox!.value!,
folderStructure: this.folderStructureDropDown!.value as string,
targetScripts: [],
dataSchemaProvider: '',
connectionDetails: connectionDetails,
databaseName: '',
serverDisplayName: '',
serverName: '',
ownerUri: '',
packageFilePath: '',
};
const model: UpdateProjectDataModel = {
sourceEndpointInfo: sourceEndpointInfo,
targetEndpointInfo: targetEndpointInfo,
action: this.action!
};
getAzdataApi()!.window.closeDialog(this.dialog);
await this.updateProjectFromDatabaseCallback!(model);
this.dispose();
}
async validate(): Promise<boolean> {
try {
if (await getDataWorkspaceExtensionApi().validateWorkspace() === false) {
return false;
}
// the selected location should be an existing directory
const parentDirectoryExists = await exists(path.dirname(this.projectFileTextBox!.value!));
if (!parentDirectoryExists) {
this.showErrorMessage(constants.ProjectParentDirectoryNotExistError(this.projectFileTextBox!.value!));
return false;
}
// the selected location must contain a .sqlproj file
const fileExists = await exists(this.projectFileTextBox!.value!);
if (!fileExists) {
this.showErrorMessage(constants.noSqlProjFile);
return false;
}
// schema compare extension must be downloaded
if (!vscode.extensions.getExtension(constants.schemaCompareExtensionId)) {
this.showErrorMessage(constants.noSchemaCompareExtension);
return false;
}
return true;
} catch (err) {
this.showErrorMessage(err?.message ? err.message : err);
return false;
}
}
protected showErrorMessage(message: string): void {
this.dialog.message = {
text: message,
level: getAzdataApi()!.window.MessageLevel.Error
};
}
}
export interface ConnectionDropdownValue extends azdata.CategoryValue {
connection: azdata.connection.ConnectionProfile;
}

View File

@@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as mssql from '../../../../mssql/src/mssql';
export interface UpdateProjectDataModel {
sourceEndpointInfo: mssql.SchemaCompareEndpointInfo;
targetEndpointInfo: mssql.SchemaCompareEndpointInfo;
action: UpdateProjectAction;
}
export const enum UpdateProjectAction {
Compare = 0,
Update = 1
}

View File

@@ -168,4 +168,19 @@ export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvide
const projectUri = getDataWorkspaceExtensionApi().openSpecificProjectNewProjectDialog(projectType);
return projectUri;
}
/**
* Gets the list of .sql scripts contained in a project
* @param projectFilePath
*/
async getProjectScriptFiles(projectFilePath: string): Promise<string[]> {
return await this.projectController.getProjectScriptFiles(projectFilePath);
}
/**
* Gets the Database Schema Provider version for a SQL project
*/
async getProjectDatabaseSchemaProvider(projectFilePath: string): Promise<string> {
return await this.projectController.getProjectDatabaseSchemaProvider(projectFilePath);
}
}

View File

@@ -35,6 +35,17 @@ declare module 'sqldbproj' {
* @returns uri of the created the project or undefined if no project was created
*/
openSqlNewProjectDialog(allowedTargetPlatforms?: SqlTargetPlatform[]): Promise<vscode.Uri | undefined>;
/**
* Gets the list of .sql scripts contained in a project
* @param projectFilePath
*/
getProjectScriptFiles(projectFilePath: string): Promise<string[]>;
/**
* Gets the Database Schema Provider version for a SQL project
*/
getProjectDatabaseSchemaProvider(projectFilePath: string): Promise<string>;
}
/**

View File

@@ -0,0 +1,75 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as should from 'should';
import * as sinon from 'sinon';
import * as baselines from '../baselines/baselines';
import * as testUtils from '../testUtils';
import { UpdateProjectFromDatabaseDialog } from '../../dialogs/updateProjectFromDatabaseDialog';
import { mockConnectionProfile } from '../testContext';
describe('Update Project From Database Dialog', () => {
before(async function (): Promise<void> {
await baselines.loadBaselines();
});
afterEach(function (): void {
sinon.restore();
});
it('Should populate endpoints correctly when no context passed', async function (): Promise<void> {
const dialog = new UpdateProjectFromDatabaseDialog(undefined, undefined);
await dialog.openDialog();
should.equal(dialog.serverDropdown!.value, undefined, `Server dropdown should not be populated, but instead was "${dialog.serverDropdown!.value}".`);
should.equal(dialog.databaseDropdown!.value, undefined, `Database dropdown should not be populated, but instead was "${dialog.databaseDropdown!.value}".`);
should.equal(dialog.projectFileTextBox!.value, '', `Project file textbox should not be populated, but instead was "${dialog.projectFileTextBox!.value}".`);
should.equal(dialog.dialog.okButton.enabled, false, 'Okay button should be disabled.');
});
it('Should populate endpoints correctly when Project context is passed', async function (): Promise<void> {
const project = await testUtils.createTestProject(baselines.openProjectFileBaseline);
const dialog = new UpdateProjectFromDatabaseDialog(undefined, project);
await dialog.openDialog();
should.equal(dialog.serverDropdown!.value, undefined, `Server dropdown should not be populated, but instead was "${dialog.serverDropdown!.value}".`);
should.equal(dialog.databaseDropdown!.value, undefined, `Database dropdown should not be populated, but instead was "${dialog.databaseDropdown!.value}".`);
should.equal(dialog.projectFileTextBox!.value, project.projectFilePath, `Project file textbox should be the sqlproj path (${project.projectFilePath}), but instead was "${dialog.projectFileTextBox!.value}".`);
should.equal(dialog.dialog.okButton.enabled, false, 'Okay button should be disabled.');
});
it('Should populate endpoints correctly when Connection context is passed', async function (): Promise<void> {
sinon.stub(azdata.connection, 'getConnections').resolves([<azdata.connection.ConnectionProfile><unknown>mockConnectionProfile]);
sinon.stub(azdata.connection, 'listDatabases').resolves([mockConnectionProfile.databaseName!]);
const profile = mockConnectionProfile;
const dialog = new UpdateProjectFromDatabaseDialog(profile, undefined);
await dialog.openDialog();
await dialog.populatedInputsPromise;
should.equal((<any>dialog.serverDropdown!.value).displayName, profile.options['connectionName'], `Server dropdown should be "${profile.options['connectionName']}", but instead was "${(<any>dialog.serverDropdown!.value).displayName}".`);
should.equal(dialog.databaseDropdown!.value, profile.databaseName, `Database dropdown should be "${profile.databaseName}", but instead was "${dialog.databaseDropdown!.value}".`);
should.equal(dialog.projectFileTextBox!.value, '', `Project file textbox should not be populated, but instead was "${dialog.projectFileTextBox!.value}".`);
should.equal(dialog.dialog.okButton.enabled, false, 'Okay button should be disabled.');
});
it('Should populate endpoints correctly when context is complete', async function (): Promise<void> {
const project = await testUtils.createTestProject(baselines.openProjectFileBaseline);
sinon.stub(azdata.connection, 'getConnections').resolves([<azdata.connection.ConnectionProfile><unknown>mockConnectionProfile]);
sinon.stub(azdata.connection, 'listDatabases').resolves([mockConnectionProfile.databaseName!]);
const profile = mockConnectionProfile;
const dialog = new UpdateProjectFromDatabaseDialog(profile, project);
await dialog.openDialog();
await dialog.populatedInputsPromise;
should.equal((<any>dialog.serverDropdown!.value).displayName, profile.options['connectionName'], `Server dropdown should be "${profile.options['connectionName']}", but instead was "${(<any>dialog.serverDropdown!.value).displayName}".`);
should.equal(dialog.databaseDropdown!.value, profile.databaseName, `Database dropdown should as "${profile.databaseName}", but instead was "${dialog.databaseDropdown!.value}".`);
should.equal(dialog.projectFileTextBox!.value, project.projectFilePath, `Project file textbox should be the sqlproj path (${project.projectFilePath}), but instead was "${dialog.projectFileTextBox!.value}".`);
should.equal(dialog.dialog.okButton.enabled, true, 'Okay button should be enabled when dialog is complete.');
});
});

View File

@@ -201,6 +201,7 @@ export const mockConnectionProfile: azdata.IConnectionProfile = {
database: 'My Database',
user: 'My User',
password: 'My Pwd',
authenticationType: 'SqlLogin'
authenticationType: 'SqlLogin',
connectionName: 'My Connection Name'
}
};