diff --git a/build/lib/extensions.js b/build/lib/extensions.js index 2cf9bca6d2..d4ac681191 100644 --- a/build/lib/extensions.js +++ b/build/lib/extensions.js @@ -253,7 +253,8 @@ const sqlBuiltInExtensions = [ 'profiler', 'admin-pack', 'big-data-cluster', - 'dacpac' + 'dacpac', + 'schema-compare' ]; const builtInExtensions = require('../builtInExtensions.json'); /** diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 9cb9021270..41108af912 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -300,7 +300,8 @@ const sqlBuiltInExtensions = [ 'profiler', 'admin-pack', 'big-data-cluster', - 'dacpac' + 'dacpac', + 'schema-compare' ]; // {{SQL CARBON EDIT}} - End diff --git a/extensions/mssql/src/contracts.ts b/extensions/mssql/src/contracts.ts index 53146fbed3..0b5437aaa9 100644 --- a/extensions/mssql/src/contracts.ts +++ b/extensions/mssql/src/contracts.ts @@ -433,4 +433,27 @@ export namespace AddServerGroupRequest { export namespace RemoveServerGroupRequest { export const type = new RequestType('cms/removeCmsServerGroup'); } -// ------------------------------- ---------------------------------------- \ No newline at end of file +// ------------------------------- ---------------------------------------- + +// ------------------------------- ----------------------------- +export interface SchemaCompareParams { + sourceEndpointInfo: azdata.SchemaCompareEndpointInfo; + targetEndpointInfo: azdata.SchemaCompareEndpointInfo; + taskExecutionMode: TaskExecutionMode; +} + + export interface SchemaCompareGenerateScriptParams { + operationId: string; + targetDatabaseName: string; + scriptFilePath: string; + taskExecutionMode: TaskExecutionMode; +} + +export namespace SchemaCompareRequest { + export const type = new RequestType('schemaCompare/compare'); +} + + export namespace SchemaCompareGenerateScriptRequest { + export const type = new RequestType('schemaCompare/generateScript'); +} +// ------------------------------- ----------------------------- diff --git a/extensions/mssql/src/features.ts b/extensions/mssql/src/features.ts index 210a8820ba..4f19c416bf 100644 --- a/extensions/mssql/src/features.ts +++ b/extensions/mssql/src/features.ts @@ -145,6 +145,64 @@ export class DacFxServicesFeature extends SqlOpsFeature { } } +export class SchemaCompareServicesFeature extends SqlOpsFeature { + private static readonly messageTypes: RPCMessageType[] = [ + contracts.SchemaCompareRequest.type, + contracts.SchemaCompareGenerateScriptRequest.type + ]; + + constructor(client: SqlOpsDataClient) { + super(client, SchemaCompareServicesFeature.messageTypes); + } + + public fillClientCapabilities(capabilities: ClientCapabilities): void { + } + + public initialize(capabilities: ServerCapabilities): void { + this.register(this.messages, { + id: UUID.generateUuid(), + registerOptions: undefined + }); + } + + protected registerProvider(options: undefined): Disposable { + const client = this._client; + let self = this; + + let schemaCompare = (sourceEndpointInfo: azdata.SchemaCompareEndpointInfo, targetEndpointInfo: azdata.SchemaCompareEndpointInfo, taskExecutionMode: azdata.TaskExecutionMode): Thenable => { + let params: contracts.SchemaCompareParams = {sourceEndpointInfo: sourceEndpointInfo, targetEndpointInfo: targetEndpointInfo, taskExecutionMode: taskExecutionMode}; + return client.sendRequest(contracts.SchemaCompareRequest.type, params).then( + r => { + return r; + }, + e => { + client.logFailedRequest(contracts.SchemaCompareRequest.type, e); + return Promise.resolve(undefined); + } + ); + }; + + let schemaCompareGenerateScript = (operationId: string, targetDatabaseName: string, scriptFilePath: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable => { + let params: contracts.SchemaCompareGenerateScriptParams = {operationId: operationId, targetDatabaseName: targetDatabaseName, scriptFilePath: scriptFilePath, taskExecutionMode: taskExecutionMode}; + return client.sendRequest(contracts.SchemaCompareGenerateScriptRequest.type, params).then( + r => { + return r; + }, + e => { + client.logFailedRequest(contracts.SchemaCompareGenerateScriptRequest.type, e); + return Promise.resolve(undefined); + } + ); + }; + + return azdata.dataprotocol.registerSchemaCompareServicesProvider({ + providerId: client.providerId, + schemaCompare, + schemaCompareGenerateScript + }); + } +} + export class AgentServicesFeature extends SqlOpsFeature { private static readonly messagesTypes: RPCMessageType[] = [ contracts.AgentJobsRequest.type, diff --git a/extensions/mssql/src/main.ts b/extensions/mssql/src/main.ts index 3db280c18a..59446a8ae1 100644 --- a/extensions/mssql/src/main.ts +++ b/extensions/mssql/src/main.ts @@ -21,7 +21,7 @@ import { CredentialStore } from './credentialstore/credentialstore'; import { AzureResourceProvider } from './resourceProvider/resourceProvider'; import * as Utils from './utils'; import { Telemetry, LanguageClientErrorHandler } from './telemetry'; -import { TelemetryFeature, AgentServicesFeature, DacFxServicesFeature } from './features'; +import { TelemetryFeature, AgentServicesFeature, DacFxServicesFeature, SchemaCompareServicesFeature } from './features'; import { AppContext } from './appContext'; import { ApiWrapper } from './apiWrapper'; import { UploadFilesCommand, MkDirCommand, SaveFileCommand, PreviewFileCommand, CopyPathCommand, DeleteFilesCommand } from './objectExplorerNodeProvider/hdfsCommands'; @@ -77,6 +77,7 @@ export async function activate(context: vscode.ExtensionContext): Promiseimportflatfile_inverse \ No newline at end of file diff --git a/extensions/schema-compare/images/light_icon.svg b/extensions/schema-compare/images/light_icon.svg new file mode 100644 index 0000000000..221399119c --- /dev/null +++ b/extensions/schema-compare/images/light_icon.svg @@ -0,0 +1 @@ +importflatfile \ No newline at end of file diff --git a/extensions/schema-compare/images/sqlserver.png b/extensions/schema-compare/images/sqlserver.png new file mode 100644 index 0000000000..d884faa14a Binary files /dev/null and b/extensions/schema-compare/images/sqlserver.png differ diff --git a/extensions/schema-compare/package.json b/extensions/schema-compare/package.json new file mode 100644 index 0000000000..51ab4649e3 --- /dev/null +++ b/extensions/schema-compare/package.json @@ -0,0 +1,52 @@ +{ + "name": "schema-compare", + "displayName": "SQL Server Schema Compare", + "description": "SQL Server Schema Compare for Azure Data Studio supports comparing the schemas of databases and dacpacs.", + "version": "0.1.0", + "publisher": "Microsoft", + "preview": true, + "engines": { + "vscode": "^1.25.0", + "sqlops": "*" + }, + "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/master/extensions/import/Microsoft_SQL_Server_Import_Extension_and_Tools_Import_Flat_File_Preview.docx", + "icon": "images/sqlserver.png", + "aiKey": "AIF-5574968e-856d-40d2-af67-c89a14e76412", + "activationEvents": [ + "*" + ], + "main": "./out/main", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/azuredatastudio.git" + }, + "extensionDependencies": [ + "Microsoft.mssql" + ], + "contributes": { + "commands": [ + { + "command": "schemaCompare.start", + "title": "Schema Compare", + "icon": { + "light": "./images/light_icon.svg", + "dark": "./images/dark_icon.svg" + } + } + ], + "menus": { + "objectExplorer/item/context": [ + { + "command": "schemaCompare.start", + "when": "connectionProvider == MSSQL && nodeType && nodeType == Database", + "group": "export" + } + ] + } + }, + "dependencies": { + "vscode-extension-telemetry": "0.0.18", + "vscode-nls": "^3.2.1" + }, + "devDependencies": {} +} diff --git a/extensions/schema-compare/src/controllers/mainController.ts b/extensions/schema-compare/src/controllers/mainController.ts new file mode 100644 index 0000000000..fd884eeca1 --- /dev/null +++ b/extensions/schema-compare/src/controllers/mainController.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { SchemaCompareDialog } from '../dialogs/schemaCompareDialog'; + +/** + * The main controller class that initializes the extension + */ +export default class MainController implements vscode.Disposable { + protected _context: vscode.ExtensionContext; + + public constructor(context: vscode.ExtensionContext) { + this._context = context; + } + + public get extensionContext(): vscode.ExtensionContext { + return this._context; + } + + public deactivate(): void { + } + + public activate(): Promise { + this.initializeSchemaCompareDialog(); + return Promise.resolve(true); + } + + private initializeSchemaCompareDialog(): void { + azdata.tasks.registerTask('schemaCompare.start', (profile: azdata.IConnectionProfile) => new SchemaCompareDialog().openDialog(profile)); + } + + public dispose(): void { + this.deactivate(); + } +} diff --git a/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts b/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts new file mode 100644 index 0000000000..e1b5e040ac --- /dev/null +++ b/extensions/schema-compare/src/dialogs/schemaCompareDialog.ts @@ -0,0 +1,462 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as nls from 'vscode-nls'; +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as os from 'os'; +import { SchemaCompareResult } from '../schemaCompareResult'; + +const localize = nls.loadMessageBundle(); +const CompareButtonText: string = localize('schemaCompareDialog.Compare', 'Compare'); +const CancelButtonText: string = localize('schemaCompareDialog.Cancel', 'Cancel'); +const SourceTextBoxLabel: string = localize('schemaCompareDialog.SourceLabel', 'Source File'); +const TargetTextBoxLabel: string = localize('schemaCompareDialog.TargetLabel', 'Target File'); +const DacpacRadioButtonLabel: string = localize('schemaCompare.dacpacRadioButtonLabel', 'Data-tier Application File (.dacpac)'); +const DatabaseRadioButtonLabel: string = localize('schemaCompare.databaseButtonLabel', 'Database'); +const SourceRadioButtonsLabel: string = localize('schemaCompare.sourceButtonsLabel', 'Source Type'); +const TargetRadioButtonsLabel: string = localize('schemaCompare.targetButtonsLabel', 'Target Type'); +const NoActiveConnectionsLabel: string = localize('schemaCompare.NoActiveConnectionsText', 'No active connections'); +const SchemaCompareLabel: string = localize('schemaCompare.dialogTitle', 'Schema Compare'); + +export class SchemaCompareDialog { + public dialog: azdata.window.Dialog; + private schemaCompareTab: azdata.window.DialogTab; + private sourceDacpacComponent: azdata.FormComponent; + private sourceTextBox: azdata.InputBoxComponent; + private sourceFileButton: azdata.ButtonComponent; + private sourceServerComponent: azdata.FormComponent; + private sourceServerDropdown: azdata.DropDownComponent; + private sourceDatabaseComponent: azdata.FormComponent; + private sourceDatabaseDropdown: azdata.DropDownComponent; + private sourceNoActiveConnectionsText: azdata.FormComponent; + private targetDacpacComponent: azdata.FormComponent; + private targetTextBox: azdata.InputBoxComponent; + private targetFileButton: azdata.ButtonComponent; + private targetServerComponent: azdata.FormComponent; + private targetServerDropdown: azdata.DropDownComponent; + private targetDatabaseComponent: azdata.FormComponent; + private targetDatabaseDropdown: azdata.DropDownComponent; + private targetNoActiveConnectionsText: azdata.FormComponent; + private formBuilder: azdata.FormBuilder; + private sourceIsDacpac: boolean; + private targetIsDacpac: boolean; + private database: string; + public dialogName: string; + + protected initializeDialog(): void { + this.schemaCompareTab = azdata.window.createTab(SchemaCompareLabel); + this.initializeSchemaCompareTab(); + this.dialog.content = [this.schemaCompareTab]; + } + + public openDialog(p: any, dialogName?: string): void { + let profile = p ? p.connectionProfile : undefined; + if (profile) { + this.database = profile.databaseName; + } + + let event = dialogName ? dialogName : null; + this.dialog = azdata.window.createModelViewDialog(SchemaCompareLabel, event); + + this.initializeDialog(); + + this.dialog.okButton.label = CompareButtonText; + this.dialog.okButton.onClick(async () => await this.execute()); + + this.dialog.cancelButton.label = CancelButtonText; + this.dialog.cancelButton.onClick(async () => await this.cancel()); + + azdata.window.openDialog(this.dialog); + } + + protected async execute(): Promise { + let sourceName: string; + let targetName: string; + + let sourceEndpointInfo: azdata.SchemaCompareEndpointInfo; + if (this.sourceIsDacpac) { + sourceName = this.sourceTextBox.value; + sourceEndpointInfo = { + endpointType: azdata.SchemaCompareEndpointType.dacpac, + databaseName: '', + ownerUri: '', + packageFilePath: this.sourceTextBox.value + }; + } else { + sourceName = (this.sourceServerDropdown.value as ConnectionDropdownValue).name + '.' + (this.sourceDatabaseDropdown.value).name; + let ownerUri = await azdata.connection.getUriForConnection((this.sourceServerDropdown.value as ConnectionDropdownValue).connection.connectionId); + + sourceEndpointInfo = { + endpointType: azdata.SchemaCompareEndpointType.database, + databaseName: (this.sourceDatabaseDropdown.value).name, + ownerUri: ownerUri, + packageFilePath: '' + }; + } + + let targetEndpointInfo: azdata.SchemaCompareEndpointInfo; + if (this.targetIsDacpac) { + targetName = this.targetTextBox.value; + targetEndpointInfo = { + endpointType: azdata.SchemaCompareEndpointType.dacpac, + databaseName: '', + ownerUri: '', + packageFilePath: this.targetTextBox.value + }; + } else { + targetName = (this.targetServerDropdown.value as ConnectionDropdownValue).name + '.' + (this.targetDatabaseDropdown.value).name; + let ownerUri = await azdata.connection.getUriForConnection((this.targetServerDropdown.value as ConnectionDropdownValue).connection.connectionId); + + targetEndpointInfo = { + endpointType: azdata.SchemaCompareEndpointType.database, + databaseName: (this.targetDatabaseDropdown.value).name, + ownerUri: ownerUri, + packageFilePath: '' + }; + } + + let schemaCompareResult = new SchemaCompareResult(sourceName, targetName, sourceEndpointInfo, targetEndpointInfo); + schemaCompareResult.start(); + } + + protected async cancel(): Promise { + } + + private initializeSchemaCompareTab(): void { + this.schemaCompareTab.registerContent(async view => { + this.sourceTextBox = view.modelBuilder.inputBox().withProperties({ + width: 275 + }).component(); + + this.targetTextBox = view.modelBuilder.inputBox().withProperties({ + width: 275 + }).component(); + + this.sourceServerComponent = await this.createSourceServerDropdown(view); + await this.populateServerDropdown(false); + + this.sourceDatabaseComponent = await this.createSourceDatabaseDropdown(view); + if ((this.sourceServerDropdown.value as ConnectionDropdownValue)) { + await this.populateDatabaseDropdown((this.sourceServerDropdown.value as ConnectionDropdownValue).connection.connectionId, false); + } + + this.targetServerComponent = await this.createTargetServerDropdown(view); + await this.populateServerDropdown(true); + + this.targetDatabaseComponent = await this.createTargetDatabaseDropdown(view); + if ((this.targetServerDropdown.value as ConnectionDropdownValue)) { + await this.populateDatabaseDropdown((this.targetServerDropdown.value as ConnectionDropdownValue).connection.connectionId, true); + } + + this.sourceDacpacComponent = await this.createFileBrowser(view, false); + this.targetDacpacComponent = await this.createFileBrowser(view, true); + + let sourceRadioButtons = await this.createSourceRadiobuttons(view); + let targetRadioButtons = await this.createTargetRadiobuttons(view); + + this.sourceNoActiveConnectionsText = await this.createNoActiveConnectionsText(view); + this.targetNoActiveConnectionsText = await this.createNoActiveConnectionsText(view); + + // if schema compare was launched from a db context menu, set that db as the source + if (this.database) { + this.formBuilder = view.modelBuilder.formContainer() + .withFormItems([ + sourceRadioButtons, + this.sourceServerComponent, + this.sourceDatabaseComponent, + targetRadioButtons, + this.targetDacpacComponent + ], { + horizontal: true + }); + } else { + this.formBuilder = view.modelBuilder.formContainer() + .withFormItems([ + sourceRadioButtons, + this.sourceDacpacComponent, + targetRadioButtons, + this.targetDacpacComponent + ], { + horizontal: true + }); + } + let formModel = this.formBuilder.component(); + await view.initializeModel(formModel); + }); + } + + private async createFileBrowser(view: azdata.ModelView, isTarget: boolean): Promise { + let currentTextbox = isTarget ? this.targetTextBox : this.sourceTextBox; + if (isTarget) { + this.targetFileButton = view.modelBuilder.button().withProperties({ + label: '•••', + }).component(); + } else { + this.sourceFileButton = view.modelBuilder.button().withProperties({ + label: '•••', + }).component(); + } + + let currentButton = isTarget ? this.targetFileButton : this.sourceFileButton; + + currentButton.onDidClick(async (click) => { + let fileUris = await vscode.window.showOpenDialog( + { + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: vscode.Uri.file(os.homedir()), + openLabel: localize('schemaCompare.openFile', 'Open'), + filters: { + 'dacpac Files': ['dacpac'], + } + } + ); + + if (!fileUris || fileUris.length === 0) { + return; + } + + let fileUri = fileUris[0]; + currentTextbox.value = fileUri.fsPath; + }); + + return { + component: currentTextbox, + title: isTarget ? TargetTextBoxLabel : SourceTextBoxLabel, + actions: [currentButton] + }; + } + + private async createSourceRadiobuttons(view: azdata.ModelView): Promise { + let dacpacRadioButton = view.modelBuilder.radioButton() + .withProperties({ + name: 'source', + label: DacpacRadioButtonLabel + }).component(); + + let databaseRadioButton = view.modelBuilder.radioButton() + .withProperties({ + name: 'source', + label: DatabaseRadioButtonLabel + }).component(); + + // show dacpac file browser + dacpacRadioButton.onDidClick(() => { + this.sourceIsDacpac = true; + this.formBuilder.removeFormItem(this.sourceNoActiveConnectionsText); + this.formBuilder.removeFormItem(this.sourceServerComponent); + this.formBuilder.removeFormItem(this.sourceDatabaseComponent); + this.formBuilder.insertFormItem(this.sourceDacpacComponent, 1, { horizontal: true }); + }); + + // show server and db dropdowns or 'No active connections' text + databaseRadioButton.onDidClick(() => { + this.sourceIsDacpac = false; + if ((this.sourceServerDropdown.value as ConnectionDropdownValue)) { + this.formBuilder.insertFormItem(this.sourceServerComponent, 1, { horizontal: true, componentWidth: 300 }); + this.formBuilder.insertFormItem(this.sourceDatabaseComponent, 2, { horizontal: true, componentWidth: 300 }); + } else { + this.formBuilder.insertFormItem(this.sourceNoActiveConnectionsText, 1, { horizontal: true }); + } + this.formBuilder.removeFormItem(this.sourceDacpacComponent); + }); + + if (this.database) { + databaseRadioButton.checked = true; + this.sourceIsDacpac = false; + } else { + dacpacRadioButton.checked = true; + this.sourceIsDacpac = true; + } + let flexRadioButtonsModel = view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withItems([dacpacRadioButton, databaseRadioButton] + ).component(); + + return { + component: flexRadioButtonsModel, + title: SourceRadioButtonsLabel + }; + } + + private async createTargetRadiobuttons(view: azdata.ModelView): Promise { + let dacpacRadioButton = view.modelBuilder.radioButton() + .withProperties({ + name: 'target', + label: DacpacRadioButtonLabel + }).component(); + + let databaseRadioButton = view.modelBuilder.radioButton() + .withProperties({ + name: 'target', + label: DatabaseRadioButtonLabel + }).component(); + + // show dacpac file browser + dacpacRadioButton.onDidClick(() => { + this.targetIsDacpac = true; + this.formBuilder.removeFormItem(this.targetNoActiveConnectionsText); + this.formBuilder.removeFormItem(this.targetServerComponent); + this.formBuilder.removeFormItem(this.targetDatabaseComponent); + this.formBuilder.addFormItem(this.targetDacpacComponent, { horizontal: true }); + }); + + // show server and db dropdowns or 'No active connections' text + databaseRadioButton.onDidClick(() => { + this.targetIsDacpac = false; + this.formBuilder.removeFormItem(this.targetDacpacComponent); + if ((this.targetServerDropdown.value as ConnectionDropdownValue)) { + this.formBuilder.addFormItem(this.targetServerComponent, { horizontal: true, componentWidth: 300 }); + this.formBuilder.addFormItem(this.targetDatabaseComponent, { horizontal: true, componentWidth: 300 }); + } else { + this.formBuilder.addFormItem(this.targetNoActiveConnectionsText, { horizontal: true }); + } + }); + + dacpacRadioButton.checked = true; + this.targetIsDacpac = true; + let flexRadioButtonsModel = view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withItems([dacpacRadioButton, databaseRadioButton] + ).component(); + + return { + component: flexRadioButtonsModel, + title: TargetRadioButtonsLabel + }; + } + + protected async createSourceServerDropdown(view: azdata.ModelView): Promise { + this.sourceServerDropdown = view.modelBuilder.dropDown().component(); + this.sourceServerDropdown.onValueChanged(async () => { + await this.populateDatabaseDropdown((this.sourceServerDropdown.value as ConnectionDropdownValue).connection.connectionId, false); + }); + + return { + component: this.sourceServerDropdown, + title: localize('schemaCompare.sourceServerDropdownTitle', 'Source Server') + }; + } + + protected async createTargetServerDropdown(view: azdata.ModelView): Promise { + this.targetServerDropdown = view.modelBuilder.dropDown().component(); + this.targetServerDropdown.onValueChanged(async () => { + await this.populateDatabaseDropdown((this.targetServerDropdown.value as ConnectionDropdownValue).connection.connectionId, true); + }); + + return { + component: this.targetServerDropdown, + title: localize('schemaCompare.targetServerDropdownTitle', 'Target Server') + }; + } + + protected async populateServerDropdown(isTarget: boolean): Promise { + let currentDropdown = isTarget ? this.targetServerDropdown : this.sourceServerDropdown; + let values = await this.getServerValues(); + + currentDropdown.updateProperties({ + values: values + }); + } + + protected async getServerValues(): Promise<{ connection: azdata.connection.Connection, displayName: string, name: string }[]> { + let cons = await azdata.connection.getActiveConnections(); + // This user has no active connections + if (!cons || cons.length === 0) { + return undefined; + } + + let values = cons.map(c => { + let db = c.options.databaseDisplayName; + let usr = c.options.user; + let srv = c.options.server; + + if (!db) { + db = ''; + } + + if (!usr) { + usr = 'default'; + } + + let finalName = `${srv}, ${db} (${usr})`; + return { + connection: c, + displayName: finalName, + name: srv + }; + }); + + return values; + } + + protected async createSourceDatabaseDropdown(view: azdata.ModelView): Promise { + this.sourceDatabaseDropdown = view.modelBuilder.dropDown().component(); + + return { + component: this.sourceDatabaseDropdown, + title: localize('schemaCompare.sourceDatabaseDropdownTitle', 'Source Database') + }; + } + + protected async createTargetDatabaseDropdown(view: azdata.ModelView): Promise { + this.targetDatabaseDropdown = view.modelBuilder.dropDown().component(); + + return { + component: this.targetDatabaseDropdown, + title: localize('schemaCompare.targetDatabaseDropdownTitle', 'Target Database') + }; + } + + protected async populateDatabaseDropdown(connectionId: string, isTarget: boolean): Promise { + let currentDropdown = isTarget ? this.targetDatabaseDropdown : this.sourceDatabaseDropdown; + currentDropdown.updateProperties({ values: [] }); + + let values = await this.getDatabaseValues(connectionId); + currentDropdown.updateProperties({ + values: values + }); + } + + protected async getDatabaseValues(connectionId: string): Promise<{ displayName, name }[]> { + let idx = -1; + let count = -1; + let values = (await azdata.connection.listDatabases(connectionId)).map(db => { + count++; + // if schema compare was launched from a db context menu, set that db at the top of the dropdown + if (this.database && db === this.database) { + idx = count; + } + + return { + displayName: db, + name: db + }; + }); + + if (idx >= 0) { + let tmp = values[0]; + values[0] = values[idx]; + values[idx] = tmp; + } + return values; + } + + protected async createNoActiveConnectionsText(view: azdata.ModelView): Promise { + let noActiveConnectionsText = view.modelBuilder.text().withProperties({ value: NoActiveConnectionsLabel }).component(); + + return { + component: noActiveConnectionsText, + title: '' + }; + } +} + +interface ConnectionDropdownValue extends azdata.CategoryValue { + connection: azdata.connection.Connection; +} \ No newline at end of file diff --git a/extensions/schema-compare/src/main.ts b/extensions/schema-compare/src/main.ts new file mode 100644 index 0000000000..5e17966ec3 --- /dev/null +++ b/extensions/schema-compare/src/main.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; +import * as vscode from 'vscode'; +import MainController from './controllers/mainController'; + +let controllers: MainController[] = []; + +export async function activate(context: vscode.ExtensionContext): Promise { + // Start the main controller + let mainController = new MainController(context); + controllers.push(mainController); + context.subscriptions.push(mainController); + + await mainController.activate(); +} + +export function deactivate(): void { + for (let controller of controllers) { + controller.deactivate(); + } +} diff --git a/extensions/schema-compare/src/media/compare-inverse.svg b/extensions/schema-compare/src/media/compare-inverse.svg new file mode 100644 index 0000000000..3cae2ff14b --- /dev/null +++ b/extensions/schema-compare/src/media/compare-inverse.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/schema-compare/src/media/compare.svg b/extensions/schema-compare/src/media/compare.svg new file mode 100644 index 0000000000..7ad2cff30d --- /dev/null +++ b/extensions/schema-compare/src/media/compare.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/schema-compare/src/media/generate-script-inverse.svg b/extensions/schema-compare/src/media/generate-script-inverse.svg new file mode 100644 index 0000000000..7f6545b590 --- /dev/null +++ b/extensions/schema-compare/src/media/generate-script-inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/schema-compare/src/media/generate-script.svg b/extensions/schema-compare/src/media/generate-script.svg new file mode 100644 index 0000000000..a279251e92 --- /dev/null +++ b/extensions/schema-compare/src/media/generate-script.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/schema-compare/src/media/switch-directions-inverse.svg b/extensions/schema-compare/src/media/switch-directions-inverse.svg new file mode 100644 index 0000000000..15c31975ef --- /dev/null +++ b/extensions/schema-compare/src/media/switch-directions-inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/schema-compare/src/media/switch-directions.svg b/extensions/schema-compare/src/media/switch-directions.svg new file mode 100644 index 0000000000..e9edd4575f --- /dev/null +++ b/extensions/schema-compare/src/media/switch-directions.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/schema-compare/src/schemaCompareResult.ts b/extensions/schema-compare/src/schemaCompareResult.ts new file mode 100644 index 0000000000..c2f740dfac --- /dev/null +++ b/extensions/schema-compare/src/schemaCompareResult.ts @@ -0,0 +1,366 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as nls from 'vscode-nls'; +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as os from 'os'; +import * as path from 'path'; +const localize = nls.loadMessageBundle(); + +export class SchemaCompareResult { + private differencesTable: azdata.TableComponent; + private loader: azdata.LoadingComponent; + private editor: azdata.workspace.ModelViewEditor; + private diffEditor: azdata.DiffEditorComponent; + private splitView: azdata.SplitViewContainer; + private flexModel: azdata.FlexContainer; + private noDifferencesLabel: azdata.TextComponent; + private sourceTargetFlexLayout: azdata.FlexContainer; + private switchButton: azdata.ButtonComponent; + private compareButton: azdata.ButtonComponent; + private generateScriptButton: azdata.ButtonComponent; + private SchemaCompareActionMap: Map; + private comparisonResult: azdata.SchemaCompareResult; + private sourceNameComponent: azdata.TableComponent; + private targetNameComponent: azdata.TableComponent; + + constructor(private sourceName: string, private targetName: string, private sourceEndpointInfo: azdata.SchemaCompareEndpointInfo, private targetEndpointInfo: azdata.SchemaCompareEndpointInfo) { + this.SchemaCompareActionMap = new Map(); + this.SchemaCompareActionMap[azdata.SchemaUpdateAction.Delete] = localize('schemaCompare.deleteAction', 'Delete'); + this.SchemaCompareActionMap[azdata.SchemaUpdateAction.Change] = localize('schemaCompare.changeAction', 'Change'); + this.SchemaCompareActionMap[azdata.SchemaUpdateAction.Add] = localize('schemaCompare.addAction', 'Add'); + + this.editor = azdata.workspace.createModelViewEditor(localize('schemaCompare.Title', 'Schema Compare'), { retainContextWhenHidden: true, supportsSave: true }); + + this.editor.registerContent(async view => { + this.differencesTable = view.modelBuilder.table().withProperties({ + data: [], + height: 300, + }).component(); + + this.diffEditor = view.modelBuilder.diffeditor().withProperties({ + contentLeft: os.EOL, + contentRight: os.EOL, + height: 500, + title: localize('schemaCompare.ObjectDefinitionsTitle', 'Object Definitions') + }).component(); + + this.splitView = view.modelBuilder.splitViewContainer().component(); + + let sourceTargetLabels = view.modelBuilder.flexContainer() + .withProperties({ + alignItems: 'stretch', + horizontal: true + }).component(); + + this.sourceTargetFlexLayout = view.modelBuilder.flexContainer() + .withProperties({ + alignItems: 'stretch', + horizontal: true + }).component(); + + this.createSwitchButton(view); + this.createCompareButton(view); + this.createGenerateScriptButton(view); + this.resetButtons(); + + let toolBar = view.modelBuilder.toolbarContainer(); + toolBar.addToolbarItems([{ + component: this.compareButton + }, { + component: this.generateScriptButton, + toolbarSeparatorAfter: true + }, + { + component: this.switchButton + }]); + + let sourceLabel = view.modelBuilder.text().withProperties({ + value: localize('schemaCompare.sourceLabel', 'Source') + }).component(); + + let targetLabel = view.modelBuilder.text().withProperties({ + value: localize('schemaCompare.targetLabel', 'Target') + }).component(); + + let arrowLabel = view.modelBuilder.text().withProperties({ + value: localize('schemaCompare.switchLabel', '➔') + }).component(); + + this.sourceNameComponent = view.modelBuilder.table().withProperties({ + columns: [ + { + value: sourceName, + headerCssClass: 'no-borders', + toolTip: sourceName + }, + ] + }).component(); + + this.targetNameComponent = view.modelBuilder.table().withProperties({ + columns: [ + { + value: targetName, + headerCssClass: 'no-borders', + toolTip: targetName + }, + ] + }).component(); + + sourceTargetLabels.addItem(sourceLabel, { CSSStyles: { 'width': '55%', 'margin-left': '15px', 'font-size': 'larger', 'font-weight': 'bold' } }); + sourceTargetLabels.addItem(targetLabel, { CSSStyles: { 'width': '45%', 'font-size': 'larger', 'font-weight': 'bold' } }); + this.sourceTargetFlexLayout.addItem(this.sourceNameComponent, { CSSStyles: { 'width': '45%', 'height': '25px', 'margin-top': '10px', 'margin-left': '15px' } }); + this.sourceTargetFlexLayout.addItem(arrowLabel, { CSSStyles: { 'width': '10%', 'font-size': 'larger', 'text-align-last': 'center' } }); + this.sourceTargetFlexLayout.addItem(this.targetNameComponent, { CSSStyles: { 'width': '45%', 'height': '25px', 'margin-top': '10px', 'margin-left': '15px' } }); + + this.loader = view.modelBuilder.loadingComponent().component(); + this.noDifferencesLabel = view.modelBuilder.text().withProperties({ + value: localize('schemaCompare.noDifferences', 'No schema differences were found') + }).component(); + + this.flexModel = view.modelBuilder.flexContainer().component(); + this.flexModel.addItem(toolBar.component(), { flex: 'none' }); + this.flexModel.addItem(sourceTargetLabels, { flex: 'none' }); + this.flexModel.addItem(this.sourceTargetFlexLayout, { flex: 'none' }); + this.flexModel.addItem(this.loader, { CSSStyles: { 'margin-top': '30px' } }); + this.flexModel.setLayout({ + flexFlow: 'column', + height: '100%' + }); + + await view.initializeModel(this.flexModel); + }); + } + + public start(): void { + this.editor.openEditor(); + this.execute(); + } + + private async execute(): Promise { + let service = await SchemaCompareResult.getService('MSSQL'); + this.comparisonResult = await service.schemaCompare(this.sourceEndpointInfo, this.targetEndpointInfo, azdata.TaskExecutionMode.execute); + if (!this.comparisonResult || !this.comparisonResult.success) { + vscode.window.showErrorMessage(localize('schemaCompare.compareErrorMessage', "Schema Compare failed: {0}", this.comparisonResult.errorMessage ? this.comparisonResult.errorMessage : 'Unknown')); + return; + } + + let data = this.getAllDifferences(this.comparisonResult.differences); + + this.differencesTable.updateProperties({ + data: data, + columns: [ + { + value: localize('schemaCompare.typeColumn', 'Type'), + cssClass: 'align-with-header', + width: 50 + }, + { + value: localize('schemaCompare.sourceNameColumn', 'Target Name'), + cssClass: 'align-with-header', + width: 90 + }, + { + value: localize('schemaCompare.actionColumn', 'Action'), + cssClass: 'align-with-header', + width: 30 + }, + { + value: localize('schemaCompare.targetNameColumn', 'Source Name'), + cssClass: 'align-with-header', + width: 150 + }] + }); + + this.splitView.addItem(this.differencesTable); + this.splitView.addItem(this.diffEditor); + this.splitView.setLayout({ + orientation: 'vertical', + splitViewHeight: 800 + }); + + this.flexModel.removeItem(this.loader); + this.switchButton.enabled = true; + this.compareButton.enabled = true; + + if (this.comparisonResult.differences.length > 0) { + this.flexModel.addItem(this.splitView); + + // only enable generate script button if the target is a db + if (this.targetEndpointInfo.endpointType === azdata.SchemaCompareEndpointType.database) { + this.generateScriptButton.enabled = true; + } else { + this.generateScriptButton.title = localize('schemaCompare.generateScriptButtonDisabledTitle', 'Generate script is enabled when the target is a database'); + } + } else { + this.flexModel.addItem(this.noDifferencesLabel, { CSSStyles: { 'margin': 'auto' } }); + } + + let sourceText = ''; + let targetText = ''; + this.differencesTable.onRowSelected(() => { + let difference = this.comparisonResult.differences[this.differencesTable.selectedRows[0]]; + if (difference !== undefined) { + sourceText = difference.sourceScript === null ? '\n' : this.getAggregatedScript(difference, true); + targetText = difference.targetScript === null ? '\n' : this.getAggregatedScript(difference, false); + + this.diffEditor.updateProperties({ + contentLeft: sourceText, + contentRight: targetText, + title: localize('schemaCompare.ObjectDefinitionsTitle', 'Object Definitions') + }); + } + }); + } + + private getAllDifferences(differences: azdata.DiffEntry[]): string[][] { + let data = []; + if (differences) { + differences.forEach(difference => { + if (difference.differenceType === azdata.SchemaDifferenceType.Object) { + if (difference.sourceValue !== null || difference.targetValue !== null) { + data.push([difference.name, difference.sourceValue, this.SchemaCompareActionMap[difference.updateAction], difference.targetValue]); + } + } + }); + } + + return data; + } + + private getAggregatedScript(diffEntry: azdata.DiffEntry, getSourceScript: boolean): string { + let script = ''; + if (diffEntry !== null) { + script += getSourceScript ? diffEntry.sourceScript : diffEntry.targetScript; + diffEntry.children.forEach(child => { + let childScript = this.getAggregatedScript(child, getSourceScript); + if (childScript !== 'null') { + script += childScript; + } + }); + } + return script; + } + + private reExecute(): void { + this.flexModel.removeItem(this.splitView); + this.flexModel.removeItem(this.noDifferencesLabel); + this.flexModel.addItem(this.loader, { CSSStyles: { 'margin-top': '30px' } }); + this.diffEditor.updateProperties({ + contentLeft: os.EOL, + contentRight: os.EOL + }); + this.differencesTable.selectedRows = null; + this.resetButtons(); + this.execute(); + } + + private createCompareButton(view: azdata.ModelView): void { + this.compareButton = view.modelBuilder.button().withProperties({ + label: localize('schemaCompare.compareButton', 'Compare'), + iconPath: { + light: path.join(__dirname, 'media', 'compare.svg'), + dark: path.join(__dirname, 'media', 'compare-inverse.svg') + }, + title: localize('schemaCompare.compareButtonTitle', 'Compare') + }).component(); + + this.compareButton.onDidClick(async (click) => { + this.reExecute(); + }); + } + + private createGenerateScriptButton(view: azdata.ModelView): void { + this.generateScriptButton = view.modelBuilder.button().withProperties({ + label: localize('schemaCompare.generateScriptButton', 'Generate script'), + iconPath: { + light : path.join(__dirname, 'media', 'generate-script.svg'), + dark: path.join(__dirname, 'media', 'generate-script-inverse.svg') + }, + }).component(); + + this.generateScriptButton.onDidClick(async (click) => { + // get file path + let now = new Date(); + let datetime = now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate() + '-' + now.getHours() + '-' + now.getMinutes() + '-' + now.getSeconds(); + let defaultFilePath = path.join(os.homedir(), this.targetName + '_Update_' + datetime + '.sql'); + let fileUri = await vscode.window.showSaveDialog( + { + defaultUri: vscode.Uri.file(defaultFilePath), + saveLabel: localize('schemaCompare.saveFile', 'Save'), + filters: { + 'SQL Files': ['sql'], + } + } + ); + + if (!fileUri) { + return; + } + + let service = await SchemaCompareResult.getService('MSSQL'); + let result = await service.schemaCompareGenerateScript(this.comparisonResult.operationId, this.targetEndpointInfo.databaseName, fileUri.fsPath, azdata.TaskExecutionMode.execute); + if (!result || !result.success) { + vscode.window.showErrorMessage( + localize('schemaCompare.generateScriptErrorMessage', "Generate script failed: '{0}'", (result && result.errorMessage) ? result.errorMessage : 'Unknown')); + } + }); + } + + private resetButtons(): void { + this.compareButton.enabled = false; + this.switchButton.enabled = false; + this.generateScriptButton.enabled = false; + this.generateScriptButton.title = localize('schemaCompare.generateScriptEnabledButton', 'Generate script to deploy changes to target'); + } + + private createSwitchButton(view: azdata.ModelView): void { + let swapIcon = path.join(__dirname, 'media', 'switch-directions.svg'); + + this.switchButton = view.modelBuilder.button().withProperties({ + label: localize('schemaCompare.switchDirectionButton', 'Switch direction'), + iconPath: { + light : path.join(__dirname, 'media', 'switch-directions.svg'), + dark: path.join(__dirname, 'media', 'switch-directions-inverse.svg') + }, + title: localize('schemaCompare.switchButtonTitle', 'Switch source and target') + }).component(); + + this.switchButton.onDidClick(async (click) => { + // switch source and target + [this.sourceEndpointInfo, this.targetEndpointInfo] = [this.targetEndpointInfo, this.sourceEndpointInfo]; + [this.sourceName, this.targetName] = [this.targetName, this.sourceName]; + + this.sourceNameComponent.updateProperties({ + columns: [ + { + value: this.sourceName, + headerCssClass: 'no-borders', + toolTip: this.sourceName + }, + ] + }); + + this.targetNameComponent.updateProperties({ + columns: [ + { + value: this.targetName, + headerCssClass: 'no-borders', + toolTip: this.targetName + }, + ] + }); + + this.reExecute(); + }); + } + + private static async getService(providerName: string): Promise { + let service = azdata.dataprotocol.getProvider(providerName, azdata.DataProviderType.SchemaCompareServicesProvider); + return service; + } +} \ No newline at end of file diff --git a/extensions/schema-compare/src/typings/ref.d.ts b/extensions/schema-compare/src/typings/ref.d.ts new file mode 100644 index 0000000000..4d46be908b --- /dev/null +++ b/extensions/schema-compare/src/typings/ref.d.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/// +/// +/// +/// \ No newline at end of file diff --git a/extensions/schema-compare/tsconfig.json b/extensions/schema-compare/tsconfig.json new file mode 100644 index 0000000000..76b02da52f --- /dev/null +++ b/extensions/schema-compare/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "outDir": "./out", + "lib": [ + "es6", + "es2015.promise" + ], + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "declaration": false + }, + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/extensions/schema-compare/yarn.lock b/extensions/schema-compare/yarn.lock new file mode 100644 index 0000000000..6ac1974f1b --- /dev/null +++ b/extensions/schema-compare/yarn.lock @@ -0,0 +1,46 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +applicationinsights@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-1.0.1.tgz#53446b830fe8d5d619eee2a278b31d3d25030927" + integrity sha1-U0Rrgw/o1dYZ7uKieLMdPSUDCSc= + dependencies: + diagnostic-channel "0.2.0" + diagnostic-channel-publishers "0.2.1" + zone.js "0.7.6" + +diagnostic-channel-publishers@0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.2.1.tgz#8e2d607a8b6d79fe880b548bc58cc6beb288c4f3" + integrity sha1-ji1geottef6IC1SLxYzGvrKIxPM= + +diagnostic-channel@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/diagnostic-channel/-/diagnostic-channel-0.2.0.tgz#cc99af9612c23fb1fff13612c72f2cbfaa8d5a17" + integrity sha1-zJmvlhLCP7H/8TYSxy8sv6qNWhc= + dependencies: + semver "^5.3.0" + +semver@^5.3.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" + integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== + +vscode-extension-telemetry@0.0.18: + version "0.0.18" + resolved "https://registry.yarnpkg.com/vscode-extension-telemetry/-/vscode-extension-telemetry-0.0.18.tgz#602ba20d8c71453aa34533a291e7638f6e5c0327" + integrity sha512-Vw3Sr+dZwl+c6PlsUwrTtCOJkgrmvS3OUVDQGcmpXWAgq9xGq6as0K4pUx+aGqTjzLAESmWSrs6HlJm6J6Khcg== + dependencies: + applicationinsights "1.0.1" + +vscode-nls@^3.2.1: + version "3.2.5" + resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-3.2.5.tgz#25520c1955108036dec607c85e00a522f247f1a4" + integrity sha512-ITtoh3V4AkWXMmp3TB97vsMaHRgHhsSFPsUdzlueSL+dRZbSNTZeOmdQv60kjCV306ghPxhDeoNUEm3+EZMuyw== + +zone.js@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.7.6.tgz#fbbc39d3e0261d0986f1ba06306eb3aeb0d22009" + integrity sha1-+7w50+AmHQmG8boGMG6zrrDSIAk= diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 342fd7e8e0..15a0158363 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -44,6 +44,8 @@ declare module 'azdata' { export function registerDacFxServicesProvider(provider: DacFxServicesProvider): vscode.Disposable; + export function registerSchemaCompareServicesProvider(provider: SchemaCompareServicesProvider): vscode.Disposable; + /** * An [event](#Event) which fires when the specific flavor of a language used in DMP * connections has changed. And example is for a SQL connection, the flavor changes @@ -1699,6 +1701,51 @@ declare module 'azdata' { generateDeployPlan(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: TaskExecutionMode): Thenable; } + // Schema Compare interfaces ----------------------------------------------------------------------- + export interface SchemaCompareResult extends ResultStatus { + operationId: string; + areEqual: boolean; + differences: DiffEntry[]; + } + + export interface DiffEntry { + updateAction: SchemaUpdateAction; + differenceType: SchemaDifferenceType; + name: string; + sourceValue: string; + targetValue: string; + parent: DiffEntry; + children: DiffEntry[]; + sourceScript: string; + targetScript: string; + } + + export enum SchemaUpdateAction { + Delete = 0, + Change = 1, + Add = 2 + } + + export enum SchemaDifferenceType { + Object = 0, + Property = 1 + } + export enum SchemaCompareEndpointType { + database = 0, + dacpac = 1 + } + export interface SchemaCompareEndpointInfo { + endpointType: SchemaCompareEndpointType; + packageFilePath: string; + databaseName: string; + ownerUri: string; + } + + export interface SchemaCompareServicesProvider extends DataProvider { + schemaCompare(sourceEndpointInfo: SchemaCompareEndpointInfo, targetEndpointInfo: SchemaCompareEndpointInfo, taskExecutionMode: TaskExecutionMode): Thenable; + schemaCompareGenerateScript(operationId: string, targetDatabaseName: string, scriptFilePath: string, taskExecutionMode: TaskExecutionMode): Thenable; + } + // Security service interfaces ------------------------------------------------------------------------ export interface CredentialInfo { id: number; @@ -2916,6 +2963,7 @@ declare module 'azdata' { value: string; width?: number; cssClass?: string; + headerCssClass?: string; toolTip?: string; } @@ -3764,6 +3812,7 @@ declare module 'azdata' { AgentServicesProvider = 'AgentServicesProvider', CapabilitiesProvider = 'CapabilitiesProvider', DacFxServicesProvider = 'DacFxServicesProvider', + SchemaCompareServicesProvider = 'SchemaCompareServicesProvider', ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider', } diff --git a/src/sql/parts/modelComponents/diffeditor.component.ts b/src/sql/parts/modelComponents/diffeditor.component.ts index 80318bb1da..1cea0cf5e0 100644 --- a/src/sql/parts/modelComponents/diffeditor.component.ts +++ b/src/sql/parts/modelComponents/diffeditor.component.ts @@ -30,7 +30,7 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; @Component({ template: `
-
+
{{_title}}
`, @@ -71,6 +71,7 @@ export default class DiffEditorComponent extends ComponentBase implements ICompo private _createEditor(): void { this._instantiationService = this._instantiationService.createChild(new ServiceCollection([IProgressService, new SimpleProgressService()])); this._editor = this._instantiationService.createInstance(TextDiffEditor); + this._editor.reverseColoring(); this._editor.create(this._el.nativeElement); this._editor.setVisible(true); let uri1 = this.createUri('source'); diff --git a/src/sql/parts/modelComponents/editor.css b/src/sql/parts/modelComponents/editor.css index a78d4fccc8..250ab7314d 100644 --- a/src/sql/parts/modelComponents/editor.css +++ b/src/sql/parts/modelComponents/editor.css @@ -13,4 +13,12 @@ modelview-diff-editor-component { height: 100%; width : 100%; display: block; +} + +.vs-dark modelview-diff-editor-title { + background: #444444; +} + +modelview-diff-editor-title { + background: #f4f4f4; } \ No newline at end of file diff --git a/src/sql/parts/modelComponents/table.component.ts b/src/sql/parts/modelComponents/table.component.ts index 8c5f6f2146..fed80a9a50 100644 --- a/src/sql/parts/modelComponents/table.component.ts +++ b/src/sql/parts/modelComponents/table.component.ts @@ -58,6 +58,7 @@ export default class TableComponent extends ComponentBase implements IComponent, field: col.value, width: col.width, cssClass: col.cssClass, + headerCssClass: col.headerCssClass, toolTip: col.toolTip }; } else { diff --git a/src/sql/parts/modelComponents/table.css b/src/sql/parts/modelComponents/table.css index 58321ba2b4..109a7f7053 100644 --- a/src/sql/parts/modelComponents/table.css +++ b/src/sql/parts/modelComponents/table.css @@ -11,4 +11,9 @@ .align-with-header { padding-left:3px !important; +} + +.no-borders +{ + border: none !important } \ No newline at end of file diff --git a/src/sql/platform/schemaCompare/common/schemaCompareService.ts b/src/sql/platform/schemaCompare/common/schemaCompareService.ts new file mode 100644 index 0000000000..b3d3033553 --- /dev/null +++ b/src/sql/platform/schemaCompare/common/schemaCompareService.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as azdata from 'azdata'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; +import { localize } from 'vs/nls'; + +export const SERVICE_ID = 'SchemaCompareService'; +export const ISchemaCompareService = createDecorator(SERVICE_ID); + +export interface ISchemaCompareService { + _serviceBrand: any; + + registerProvider(providerId: string, provider: azdata.SchemaCompareServicesProvider): void; + schemaCompare(sourceEndpointInfo: azdata.SchemaCompareEndpointInfo, targetEndpointInfo: azdata.SchemaCompareEndpointInfo, taskExecutionMode: azdata.TaskExecutionMode): void; + schemaCompareGenerateScript(operationId: string, targetDatabaseName: string, scriptFilePath: string, taskExecutionMode: azdata.TaskExecutionMode): void; +} + +export class SchemaCompareService implements ISchemaCompareService { + _serviceBrand: any; + private _providers: { [handle: string]: azdata.SchemaCompareServicesProvider; } = Object.create(null); + + constructor(@IConnectionManagementService private _connectionService: IConnectionManagementService) { } + + registerProvider(providerId: string, provider: azdata.SchemaCompareServicesProvider): void { + this._providers[providerId] = provider; + } + + schemaCompare(sourceEndpointInfo: azdata.SchemaCompareEndpointInfo, targetEndpointInfo: azdata.SchemaCompareEndpointInfo, taskExecutionMode: azdata.TaskExecutionMode): Thenable { + return this._runAction(sourceEndpointInfo.ownerUri, (runner) => { + return runner.schemaCompare(sourceEndpointInfo, targetEndpointInfo, taskExecutionMode); + }); + } + + schemaCompareGenerateScript(operationId: string, targetDatabaseName: string, scriptFilePath: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { + return this._runAction('', (runner) => { + return runner.schemaCompareGenerateScript(operationId, targetDatabaseName, scriptFilePath, taskExecutionMode); + }); + } + + private _runAction(uri: string, action: (handler: azdata.SchemaCompareServicesProvider) => Thenable): Thenable { + let providerId: string = this._connectionService.getProviderIdFromUri(uri); + + if (!providerId) { + return Promise.reject(new Error(localize('providerIdNotValidError', "Connection is required in order to interact with SchemaCompareService"))); + } + let handler = this._providers[providerId]; + if (handler) { + return action(handler); + } else { + return Promise.reject(new Error(localize('noHandlerRegistered', "No Handler Registered"))); + } + } +} \ No newline at end of file diff --git a/src/sql/sqlops.d.ts b/src/sql/sqlops.d.ts index 0a15d2341c..da331c72d2 100644 --- a/src/sql/sqlops.d.ts +++ b/src/sql/sqlops.d.ts @@ -39,6 +39,7 @@ declare module 'sqlops' { export function registerDacFxServicesProvider(provider: DacFxServicesProvider): vscode.Disposable; + /** * An [event](#Event) which fires when the specific flavor of a language used in DMP * connections has changed. And example is for a SQL connection, the flavor changes @@ -1324,6 +1325,7 @@ declare module 'sqlops' { generateDeployPlan(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: TaskExecutionMode): Thenable; } + // Security service interfaces ------------------------------------------------------------------------ export interface CredentialInfo { id: number; diff --git a/src/sql/sqlops.proposed.d.ts b/src/sql/sqlops.proposed.d.ts index 622466a712..ac8b441899 100644 --- a/src/sql/sqlops.proposed.d.ts +++ b/src/sql/sqlops.proposed.d.ts @@ -1602,6 +1602,7 @@ declare module 'sqlops' { AgentServicesProvider = 'AgentServicesProvider', CapabilitiesProvider = 'CapabilitiesProvider', DacFxServicesProvider = 'DacFxServicesProvider', + SchemaCompareServicesProvider = 'SchemaCompareServicesProvider', ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider' } diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index e7662b03cb..a2a9ee55d0 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -292,6 +292,7 @@ export enum DataProviderType { AgentServicesProvider = 'AgentServicesProvider', CapabilitiesProvider = 'CapabilitiesProvider', DacFxServicesProvider = 'DacFxServicesProvider', + SchemaCompareServicesProvider = 'SchemaCompareServicesProvider', ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider' } @@ -547,3 +548,19 @@ export class ConnectionProfile { return undefined; } } + +export enum SchemaUpdateAction { + Delete = 0, + Change = 1, + Add = 2 +} + + export enum SchemaDifferenceType { + Object = 0, + Property = 1 +} + + export enum SchemaCompareEndpointType { + database = 0, + dacpac = 1 +} diff --git a/src/sql/workbench/api/node/extHostDataProtocol.ts b/src/sql/workbench/api/node/extHostDataProtocol.ts index 600b0c222f..2e184f5ece 100644 --- a/src/sql/workbench/api/node/extHostDataProtocol.ts +++ b/src/sql/workbench/api/node/extHostDataProtocol.ts @@ -167,6 +167,12 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { return rt; } + $registerSchemaCompareServiceProvider(provider: azdata.SchemaCompareServicesProvider): vscode.Disposable { + let rt = this.registerProvider(provider, DataProviderType.SchemaCompareServicesProvider); + this._proxy.$registerSchemaCompareServicesProvider(provider.providerId, provider.handle); + return rt; + } + // Capabilities Discovery handlers $getServerCapabilities(handle: number, client: azdata.DataProtocolClientCapabilities): Thenable { return this._resolveProvider(handle).getServerCapabilities(client); diff --git a/src/sql/workbench/api/node/mainThreadDataProtocol.ts b/src/sql/workbench/api/node/mainThreadDataProtocol.ts index c2cf3b71f0..f4229c382f 100644 --- a/src/sql/workbench/api/node/mainThreadDataProtocol.ts +++ b/src/sql/workbench/api/node/mainThreadDataProtocol.ts @@ -26,6 +26,7 @@ import { ISerializationService } from 'sql/platform/serialization/common/seriali import { IFileBrowserService } from 'sql/platform/fileBrowser/common/interfaces'; import { IExtHostContext } from 'vs/workbench/api/common/extHost.protocol'; import { IDacFxService } from 'sql/platform/dacfx/common/dacFxService'; +import { ISchemaCompareService } from 'sql/platform/schemaCompare/common/schemaCompareService'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; /** @@ -57,6 +58,7 @@ export class MainThreadDataProtocol implements MainThreadDataProtocolShape { @ISerializationService private _serializationService: ISerializationService, @IFileBrowserService private _fileBrowserService: IFileBrowserService, @IDacFxService private _dacFxService: IDacFxService, + @ISchemaCompareService private _schemaCompareService: ISchemaCompareService, ) { if (extHostContext) { this._proxy = extHostContext.getProxy(SqlExtHostContext.ExtHostDataProtocol); @@ -453,6 +455,20 @@ export class MainThreadDataProtocol implements MainThreadDataProtocolShape { return undefined; } + public $registerSchemaCompareServicesProvider(providerId: string, handle: number): Promise { + const self = this; + this._schemaCompareService.registerProvider(providerId, { + schemaCompare(sourceEndpointInfo: azdata.SchemaCompareEndpointInfo, targetEndpointInfo: azdata.SchemaCompareEndpointInfo, taskExecutionMode: azdata.TaskExecutionMode): Thenable { + return self._proxy.$schemaCompare(handle, sourceEndpointInfo, targetEndpointInfo, taskExecutionMode); + }, + schemaCompareGenerateScript(operationId: string, targetDatabaseName: string, scriptFilePath: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { + return self._proxy.$schemaCompareGenerateScript(handle, operationId, targetDatabaseName, scriptFilePath, taskExecutionMode); + } + }); + + return undefined; + } + // Connection Management handlers public $onConnectionComplete(handle: number, connectionInfoSummary: azdata.ConnectionInfoSummary): void { this._connectionManagementService.onConnectionComplete(handle, connectionInfoSummary); diff --git a/src/sql/workbench/api/node/sqlExtHost.api.impl.ts b/src/sql/workbench/api/node/sqlExtHost.api.impl.ts index 9aab0715c3..74f93700fb 100644 --- a/src/sql/workbench/api/node/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/node/sqlExtHost.api.impl.ts @@ -354,6 +354,10 @@ export function createApiFactory( return extHostDataProvider.$registerDacFxServiceProvider(provider); }; + let registerSchemaCompareServicesProvider = (provider: azdata.SchemaCompareServicesProvider): vscode.Disposable => { + return extHostDataProvider.$registerSchemaCompareServiceProvider(provider); + }; + // namespace: dataprotocol const dataprotocol: typeof azdata.dataprotocol = { registerBackupProvider, @@ -371,6 +375,7 @@ export function createApiFactory( registerAgentServicesProvider, registerCapabilitiesServiceProvider, registerDacFxServicesProvider, + registerSchemaCompareServicesProvider, onDidChangeLanguageFlavor(listener: (e: azdata.DidChangeLanguageFlavorParams) => any, thisArgs?: any, disposables?: extHostTypes.Disposable[]) { return extHostDataProvider.onDidChangeLanguageFlavor(listener, thisArgs, disposables); }, @@ -528,7 +533,10 @@ export function createApiFactory( nb: nb, AzureResource: sqlExtHostTypes.AzureResource, TreeItem: sqlExtHostTypes.TreeItem, - extensions: extensions + extensions: extensions, + SchemaUpdateAction: sqlExtHostTypes.SchemaUpdateAction, + SchemaDifferenceType: sqlExtHostTypes.SchemaDifferenceType, + SchemaCompareEndpointType: sqlExtHostTypes.SchemaCompareEndpointType }; }, @@ -754,6 +762,7 @@ export function createApiFactory( return extHostDataProvider.$registerDacFxServiceProvider(provider); }; + // namespace: dataprotocol const dataprotocol: typeof sqlops.dataprotocol = { registerBackupProvider, diff --git a/src/sql/workbench/api/node/sqlExtHost.protocol.ts b/src/sql/workbench/api/node/sqlExtHost.protocol.ts index 58dedf080f..16a5ca3b6c 100644 --- a/src/sql/workbench/api/node/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/node/sqlExtHost.protocol.ts @@ -455,6 +455,15 @@ export abstract class ExtHostDataProtocolShape { */ $generateDeployPlan(handle: number, packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { throw ni(); } + /** + * Schema compare + */ + $schemaCompare(handle: number, sourceEndpointInfo: azdata.SchemaCompareEndpointInfo, targetEndpointInfo: azdata.SchemaCompareEndpointInfo, taskExecutionMode: azdata.TaskExecutionMode): Thenable { throw ni(); } + + /** + * Schema compare generate script + */ + $schemaCompareGenerateScript(handle: number, operationId: string, targetDatabaseName: string, scriptFilePath: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { throw ni(); } } /** @@ -524,6 +533,7 @@ export interface MainThreadDataProtocolShape extends IDisposable { $registerAdminServicesProvider(providerId: string, handle: number): Promise; $registerAgentServicesProvider(providerId: string, handle: number): Promise; $registerDacFxServicesProvider(providerId: string, handle: number): Promise; + $registerSchemaCompareServicesProvider(providerId: string, handle: number): Promise; $unregisterProvider(handle: number): Promise; $onConnectionComplete(handle: number, connectionInfoSummary: azdata.ConnectionInfoSummary): void; $onIntelliSenseCacheComplete(handle: number, connectionUri: string): void; diff --git a/src/vs/workbench/workbench.main.ts b/src/vs/workbench/workbench.main.ts index ccb722f5df..1420d6891e 100644 --- a/src/vs/workbench/workbench.main.ts +++ b/src/vs/workbench/workbench.main.ts @@ -195,6 +195,7 @@ import { IAdminService, AdminService } from 'sql/workbench/services/admin/common import { IJobManagementService } from 'sql/platform/jobManagement/common/interfaces'; import { JobManagementService } from 'sql/platform/jobManagement/common/jobManagementService'; import { IDacFxService, DacFxService } from 'sql/platform/dacfx/common/dacFxService'; +import { ISchemaCompareService, SchemaCompareService } from 'sql/platform/schemaCompare/common/schemaCompareService'; import { IBackupService } from 'sql/platform/backup/common/backupService'; import { BackupService } from 'sql/platform/backup/common/backupServiceImp'; import { IBackupUiService } from 'sql/workbench/services/backup/common/backupUiService'; @@ -269,6 +270,7 @@ registerSingleton(INotebookService, NotebookService); registerSingleton(IAccountPickerService, AccountPickerService); registerSingleton(IProfilerService, ProfilerService); registerSingleton(IDacFxService, DacFxService); +registerSingleton(ISchemaCompareService, SchemaCompareService); // {{SQL CARBON EDIT}} - End //#region --- workbench parts