/*--------------------------------------------------------------------------------------------- * 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 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, getDataWorkspaceExtensionApi } from './utils'; import { SchemaCompareDialog } from './dialogs/schemaCompareDialog'; import { isNullOrUndefined } from 'util'; // Do not localize this, this is used to decide the icon for the editor. // TODO : In future icon should be decided based on language id (scmp) and not resource name const schemaCompareResourceName = 'Schema Compare'; enum ResetButtonState { noSourceTarget, beforeCompareStart, comparing, afterCompareComplete } export class SchemaCompareMainWindow { private differencesTable: azdata.TableComponent; private loader: azdata.LoadingComponent; private startText: azdata.TextComponent; private waitText: azdata.TextComponent; 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; protected switchButton: azdata.ButtonComponent; protected compareButton: azdata.ButtonComponent; protected cancelCompareButton: azdata.ButtonComponent; protected optionsButton: azdata.ButtonComponent; protected generateScriptButton: azdata.ButtonComponent; protected applyButton: azdata.ButtonComponent; protected openScmpButton: azdata.ButtonComponent; protected selectSourceButton: azdata.ButtonComponent; protected selectTargetButton: azdata.ButtonComponent; protected saveScmpButton: azdata.ButtonComponent; private SchemaCompareActionMap: Map; private operationId: string; protected comparisonResult: mssql.SchemaCompareResult; private sourceNameComponent: azdata.InputBoxComponent; private targetNameComponent: azdata.InputBoxComponent; private deploymentOptions: mssql.DeploymentOptions; private schemaCompareOptionDialog: SchemaCompareOptionsDialog; private tablelistenersToDispose: vscode.Disposable[] = []; private originalSourceExcludes = new Map(); private originalTargetExcludes = new Map(); private sourceTargetSwitched = false; private sourceName: string; private targetName: string; private scmpSourceExcludes: mssql.SchemaCompareObjectId[]; private scmpTargetExcludes: mssql.SchemaCompareObjectId[]; private diffEntryRowMap = new Map(); private showIncludeExcludeWaitingMessage: boolean = true; public sourceEndpointInfo: mssql.SchemaCompareEndpointInfo; public targetEndpointInfo: mssql.SchemaCompareEndpointInfo; public promise; public schemaCompareDialog: SchemaCompareDialog; constructor(private schemaCompareService?: mssql.ISchemaCompareService, private extensionContext?: vscode.ExtensionContext, private view?: azdata.ModelView) { this.SchemaCompareActionMap = new Map(); this.SchemaCompareActionMap[mssql.SchemaUpdateAction.Delete] = loc.deleteAction; this.SchemaCompareActionMap[mssql.SchemaUpdateAction.Change] = loc.changeAction; this.SchemaCompareActionMap[mssql.SchemaUpdateAction.Add] = loc.addAction; this.editor = azdata.workspace.createModelViewEditor(loc.SchemaCompareLabel, { retainContextWhenHidden: true, supportsSave: true, resourceName: schemaCompareResourceName }, 'SchemaCompareEditor'); } // schema compare can get started with four contexts for the source: // 1. undefined // 2. connection profile // 3. dacpac // 4. project public async start(sourceContext: any, targetContext: mssql.SchemaCompareEndpointInfo = undefined, comparisonResult: mssql.SchemaCompareResult = undefined): Promise { 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 let profile: azdata.IConnectionProfile; if (targetIsSetAsProject) { profile = sourceContext; target = targetContext; } else { profile = sourceContext ? sourceContext.connectionProfile : undefined; } let sourceDacpac = undefined; let sourceProject = undefined; if (!profile && sourceContext as string && (sourceContext as string).endsWith('.dacpac')) { sourceDacpac = sourceContext as string; } else if (!profile) { sourceProject = sourceContext as string; } if (profile) { let ownerUri = await azdata.connection.getUriForConnection((profile.id)); let usr = profile.userName; if (!usr) { usr = loc.defaultText; } source = { endpointType: mssql.SchemaCompareEndpointType.Database, serverDisplayName: `${profile.serverName} (${usr})`, serverName: profile.serverName, databaseName: profile.databaseName, ownerUri: ownerUri, packageFilePath: '', connectionDetails: undefined, connectionName: profile.connectionName, projectFilePath: '', targetScripts: [], dataSchemaProvider: '', extractTarget: mssql.ExtractTarget.schemaObjectType }; } else if (sourceDacpac) { source = { endpointType: mssql.SchemaCompareEndpointType.Dacpac, serverDisplayName: '', serverName: '', databaseName: '', ownerUri: '', packageFilePath: sourceDacpac, connectionDetails: undefined, projectFilePath: '', targetScripts: [], dataSchemaProvider: '', extractTarget: mssql.ExtractTarget.schemaObjectType }; } else if (sourceProject) { source = { endpointType: mssql.SchemaCompareEndpointType.Project, packageFilePath: '', serverDisplayName: '', serverName: '', databaseName: '', ownerUri: '', connectionDetails: undefined, projectFilePath: sourceProject, targetScripts: [], dataSchemaProvider: undefined, extractTarget: mssql.ExtractTarget.schemaObjectType }; } 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 { 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 (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(); } } private async registerContent(): Promise { return new Promise((resolve) => { this.editor.registerContent(async (view) => { if (isNullOrUndefined(this.view)) { this.view = view; } this.differencesTable = this.view.modelBuilder.table().withProps({ data: [], title: loc.differencesTableTitle, columns: [] }).component(); this.diffEditor = this.view.modelBuilder.diffeditor().withProperties({ contentLeft: os.EOL, contentRight: os.EOL, height: 500, title: loc.diffEditorTitle }).component(); this.splitView = this.view.modelBuilder.splitViewContainer().component(); let sourceTargetLabels = this.view.modelBuilder.flexContainer().component(); this.sourceTargetFlexLayout = this.view.modelBuilder.flexContainer().component(); this.createSwitchButton(); this.createCompareButton(); this.createCancelButton(); this.createGenerateScriptButton(); this.createApplyButton(); this.createOptionsButton(); this.createOpenScmpButton(); this.createSaveScmpButton(); this.createSourceAndTargetButtons(); this.sourceName = getEndpointName(this.sourceEndpointInfo); this.targetName = getEndpointName(this.targetEndpointInfo); this.sourceNameComponent = this.view.modelBuilder.inputBox().withProps({ value: this.sourceName, title: this.sourceName, enabled: false }).component(); this.targetNameComponent = this.view.modelBuilder.inputBox().withProps({ value: this.targetName, title: this.targetName, enabled: false }).component(); this.resetButtons(ResetButtonState.noSourceTarget); let toolBar = this.view.modelBuilder.toolbarContainer(); toolBar.addToolbarItems([{ component: this.compareButton }, { component: this.cancelCompareButton }, { component: this.generateScriptButton }, { component: this.applyButton }, { component: this.optionsButton, toolbarSeparatorAfter: true }, { component: this.switchButton, toolbarSeparatorAfter: true }, { component: this.openScmpButton }, { component: this.saveScmpButton }]); let sourceLabel = this.view.modelBuilder.text().withProps({ value: loc.sourceTitle, CSSStyles: { 'margin-bottom': '0px' } }).component(); let targetLabel = this.view.modelBuilder.text().withProps({ value: loc.targetTitle, CSSStyles: { 'margin-bottom': '0px' } }).component(); let arrowLabel = this.view.modelBuilder.text().withProps({ // allow-any-unicode-next-line value: '➔' }).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': '40%', 'height': '25px', 'margin-top': '10px', 'margin-left': '15px', 'margin-right': '10px' } }); this.sourceTargetFlexLayout.addItem(this.selectSourceButton, { CSSStyles: { 'margin-top': '10px' } }); this.sourceTargetFlexLayout.addItem(arrowLabel, { CSSStyles: { 'width': '10%', 'font-size': 'larger', 'text-align-last': 'center' } }); this.sourceTargetFlexLayout.addItem(this.targetNameComponent, { CSSStyles: { 'width': '40%', 'height': '25px', 'margin-top': '10px', 'margin-left': '15px', 'margin-right': '10px' } }); this.sourceTargetFlexLayout.addItem(this.selectTargetButton, { CSSStyles: { 'margin-top': '10px' } }); this.loader = this.view.modelBuilder.loadingComponent().component(); this.waitText = this.view.modelBuilder.text().withProps({ value: loc.waitText }).component(); this.startText = this.view.modelBuilder.text().withProps({ value: loc.startText }).component(); this.noDifferencesLabel = this.view.modelBuilder.text().withProps({ value: loc.noDifferencesText }).component(); this.flexModel = this.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.startText, { CSSStyles: { 'margin': 'auto' } }); this.flexModel.setLayout({ flexFlow: 'column', height: '100%' }); await this.view.initializeModel(this.flexModel); resolve(); }); }); } // update source and target name to display public updateSourceAndTarget() { this.sourceName = getEndpointName(this.sourceEndpointInfo); this.targetName = getEndpointName(this.targetEndpointInfo); this.sourceNameComponent.updateProperties({ value: this.sourceName, title: this.sourceName }); this.targetNameComponent.updateProperties({ value: this.targetName, title: this.targetName }); if (!this.sourceName || !this.targetName || this.sourceName === ' ' || this.targetName === ' ') { this.resetButtons(ResetButtonState.noSourceTarget); } else { // reset buttons to before comparison state this.resetButtons(ResetButtonState.beforeCompareStart); } } public setDeploymentOptions(deploymentOptions: mssql.DeploymentOptions): void { this.deploymentOptions = deploymentOptions; } private async populateProjectScripts(endpointInfo: mssql.SchemaCompareEndpointInfo): Promise { 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(); if (comparisonResult) { this.operationId = comparisonResult.operationId; this.comparisonResult = comparisonResult; this.flexModel.removeItem(this.startText); } else { TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaComparisonStarted'); if (!this.operationId) { // create once per page 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) { TelemetryReporter.createErrorEvent2(TelemetryViews.SchemaCompareMainWindow, 'SchemaComparisonFailed', undefined, undefined, getTelemetryErrorType(this.comparisonResult?.errorMessage)) .withAdditionalProperties({ operationId: this.comparisonResult.operationId }).send(); vscode.window.showErrorMessage(loc.compareErrorMessage(this.comparisonResult?.errorMessage)); // reset state so a new comparison can be made this.resetWindow(); return; } TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaComparisonFinished') .withAdditionalProperties({ 'endTime': Date.now().toString(), 'operationId': this.comparisonResult.operationId }).send(); } let data = this.getAllDifferences(this.comparisonResult.differences); this.differencesTable.updateProperties({ data: data, columns: [ { value: loc.type, cssClass: 'align-with-header', width: 50 }, { value: loc.sourceName, cssClass: 'align-with-header', width: 90 }, { value: loc.include, cssClass: 'align-with-header', width: 60, type: azdata.ColumnType.checkBox, action: azdata.ActionOnCellCheckboxCheck.customAction }, { value: loc.action, cssClass: 'align-with-header', width: 30 }, { value: loc.targetName, cssClass: 'align-with-header', width: 150 } ], CSSStyles: { 'left': '15px' }, width: '98%' }); this.flexModel.removeItem(this.loader); this.flexModel.removeItem(this.waitText); this.resetButtons(ResetButtonState.afterCompareComplete); if (this.comparisonResult.differences.length > 0) { this.flexModel.addItem(this.splitView); this.splitView.addItem(this.differencesTable); this.splitView.addItem(this.diffEditor); this.splitView.setLayout({ orientation: 'vertical', splitViewHeight: 800 }); // create a map of the differences to row numbers for (let i = 0; i < data.length; ++i) { this.diffEntryRowMap.set(this.createDiffEntryKey(this.comparisonResult.differences[i]), i); } // only enable generate script button if the target is a db if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Database) { this.generateScriptButton.enabled = true; } else { this.generateScriptButton.title = loc.generateScriptDisabled; } // only enable apply button if the target is a db or a project if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Database || this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Project) { this.applyButton.enabled = true; } else { this.applyButton.title = loc.applyDisabled; } } else { this.flexModel.addItem(this.noDifferencesLabel, { CSSStyles: { 'margin': 'auto' } }); } // explicitly exclude things that were excluded in previous compare const thingsToExclude = this.sourceTargetSwitched ? this.originalTargetExcludes : this.originalSourceExcludes; if (thingsToExclude) { thingsToExclude.forEach(async (item) => { await service.schemaCompareIncludeExcludeNode(this.comparisonResult.operationId, item, false, azdata.TaskExecutionMode.execute); }); // disable apply and generate script buttons if no changes are included if (thingsToExclude.size === this.comparisonResult.differences.length) { this.setButtonStatesForNoChanges(false); } } let sourceText = ''; let targetText = ''; this.tablelistenersToDispose.push(this.differencesTable.onRowSelected(() => { let difference = this.comparisonResult.differences[this.differencesTable.selectedRows[0]]; if (difference !== undefined) { sourceText = this.getFormattedScript(difference, true); targetText = this.getFormattedScript(difference, false); this.diffEditor.updateProperties({ contentLeft: sourceText, contentRight: targetText, title: loc.diffEditorTitle }); } })); this.tablelistenersToDispose.push(this.differencesTable.onCellAction(async (rowState) => { let checkboxState = rowState; if (checkboxState) { await this.applyIncludeExclude(checkboxState); } })); } public async applyIncludeExclude(checkboxState: azdata.ICheckboxCellActionEventArgs): Promise { const service = await this.getService(); // show an info notification the first time when trying to exclude to notify the user that it may take some time to calculate affected dependencies if (this.showIncludeExcludeWaitingMessage) { this.showIncludeExcludeWaitingMessage = false; vscode.window.showInformationMessage(loc.includeExcludeInfoMessage); } let diff = this.comparisonResult.differences[checkboxState.row]; const result = await service.schemaCompareIncludeExcludeNode(this.comparisonResult.operationId, diff, checkboxState.checked, azdata.TaskExecutionMode.execute); let checkboxesToChange = []; if (result.success) { this.saveExcludeState(checkboxState); // dependencies could have been included or excluded as a result, so save their exclude states result.affectedDependencies.forEach(difference => { // find the row of the difference and set its checkbox const diffEntryKey = this.createDiffEntryKey(difference); if (this.diffEntryRowMap.has(diffEntryKey)) { const row = this.diffEntryRowMap.get(diffEntryKey); checkboxesToChange.push({ row: row, column: 2, columnName: 'Include', checked: difference.included }); const dependencyCheckBoxState: azdata.ICheckboxCellActionEventArgs = { checked: difference.included, row: row, column: 2, columnName: undefined }; this.saveExcludeState(dependencyCheckBoxState); } }); } else { // failed because of dependencies if (result.blockingDependencies) { // show the first dependent that caused this to fail in the warning message const diffEntryName = this.createName(diff.sourceValue ? diff.sourceValue : diff.targetValue); const firstDependentName = this.createName(result.blockingDependencies[0].sourceValue ? result.blockingDependencies[0].sourceValue : result.blockingDependencies[0].targetValue); let cannotExcludeMessage: string; let cannotIncludeMessage: string; if (firstDependentName) { cannotExcludeMessage = loc.cannotExcludeMessageDependent(diffEntryName, firstDependentName); cannotIncludeMessage = loc.cannotIncludeMessageDependent(diffEntryName, firstDependentName); } else { cannotExcludeMessage = loc.cannotExcludeMessage(diffEntryName); cannotIncludeMessage = loc.cannotIncludeMessage(diffEntryName); } vscode.window.showWarningMessage(checkboxState.checked ? cannotIncludeMessage : cannotExcludeMessage); } else { vscode.window.showWarningMessage(result.errorMessage); } // set checkbox back to previous state checkboxesToChange.push({ row: checkboxState.row, column: checkboxState.column, columnName: 'Include', checked: !checkboxState.checked }); } if (checkboxesToChange.length > 0) { this.differencesTable.updateCells = checkboxesToChange; } } // save state based on source name if present otherwise target name (parity with SSDT) private saveExcludeState(rowState: azdata.ICheckboxCellActionEventArgs) { if (rowState) { if (this.differencesTable.data[rowState.row]?.length > 2) { this.differencesTable.data[rowState.row][2] = rowState.checked; } let diff = this.comparisonResult.differences[rowState.row]; let key = (diff.sourceValue && diff.sourceValue.length > 0) ? this.createName(diff.sourceValue) : this.createName(diff.targetValue); if (key) { if (!this.sourceTargetSwitched) { this.originalSourceExcludes.delete(key); this.removeExcludeEntry(this.scmpSourceExcludes, key); if (!rowState.checked) { this.originalSourceExcludes.set(key, diff); if (this.originalSourceExcludes.size === this.comparisonResult.differences.length) { this.setButtonStatesForNoChanges(false); } } else { this.setButtonStatesForNoChanges(true); } } else { this.originalTargetExcludes.delete(key); this.removeExcludeEntry(this.scmpTargetExcludes, key); if (!rowState.checked) { this.originalTargetExcludes.set(key, diff); if (this.originalTargetExcludes.size === this.comparisonResult.differences.length) { this.setButtonStatesForNoChanges(false); } else { this.setButtonStatesForNoChanges(true); } } } } } } private shouldDiffBeIncluded(diff: mssql.DiffEntry): boolean { let key = (diff.sourceValue && diff.sourceValue.length > 0) ? this.createName(diff.sourceValue) : this.createName(diff.targetValue); if (key) { if (this.sourceTargetSwitched === true && (this.originalTargetExcludes.has(key) || this.hasExcludeEntry(this.scmpTargetExcludes, key))) { this.originalTargetExcludes.set(key, diff); return false; } if (this.sourceTargetSwitched === false && (this.originalSourceExcludes.has(key) || this.hasExcludeEntry(this.scmpSourceExcludes, key))) { this.originalSourceExcludes.set(key, diff); return false; } return true; } return true; } private hasExcludeEntry(collection: mssql.SchemaCompareObjectId[], entryName: string): boolean { let found = false; if (collection) { const index = collection.findIndex(e => this.createName(e.nameParts) === entryName); found = index !== -1; } return found; } private removeExcludeEntry(collection: mssql.SchemaCompareObjectId[], entryName: string) { if (collection) { const index = collection.findIndex(e => this.createName(e.nameParts) === entryName); collection.splice(index, 1); } } private getAllDifferences(differences: mssql.DiffEntry[]): string[][] { let data = []; let finalDifferences: mssql.DiffEntry[] = []; if (differences) { differences.forEach(difference => { if (difference.differenceType === mssql.SchemaDifferenceType.Object) { if ((difference.sourceValue !== null && difference.sourceValue.length > 0) || (difference.targetValue !== null && difference.targetValue.length > 0)) { finalDifferences.push(difference); // Add only non-null changes to ensure index does not mismatch between dictionay and UI - #6234 let state: boolean = this.shouldDiffBeIncluded(difference); data.push([difference.name, this.createName(difference.sourceValue), state, this.SchemaCompareActionMap[difference.updateAction], this.createName(difference.targetValue)]); } } }); } this.comparisonResult.differences = finalDifferences; return data; } private createName(nameParts: string[]): string { if (isNullOrUndefined(nameParts) || nameParts.length === 0) { return ''; } return nameParts.join('.'); } private createDiffEntryKey(entry: mssql.DiffEntry): string { return `${this.createName(entry.sourceValue)}_${this.createName(entry.targetValue)}_${entry.updateAction}_${entry.name}`; } private getFormattedScript(diffEntry: mssql.DiffEntry, getSourceScript: boolean): string { // if there is no entry, the script has to be \n because an empty string shows up as a difference but \n doesn't if ((getSourceScript && diffEntry.sourceScript === null) || (!getSourceScript && diffEntry.targetScript === null)) { return '\n'; } let script = this.getAggregatedScript(diffEntry, getSourceScript); return script; } private getAggregatedScript(diffEntry: mssql.DiffEntry, getSourceScript: boolean): string { let script = ''; if (diffEntry !== null) { let diffEntryScript = getSourceScript ? diffEntry.sourceScript : diffEntry.targetScript; if (diffEntryScript) { // add a blank line between each statement script += diffEntryScript + '\n\n'; } diffEntry.children.forEach(child => { let childScript = this.getAggregatedScript(child, getSourceScript); script += childScript; }); } return script; } public async startCompare(): Promise { this.flexModel.removeItem(this.splitView); this.flexModel.removeItem(this.noDifferencesLabel); this.flexModel.removeItem(this.startText); this.flexModel.addItem(this.loader, { CSSStyles: { 'margin-top': '30px' } }); this.flexModel.addItem(this.waitText, { CSSStyles: { 'margin-top': '30px', 'align-self': 'center' } }); this.showIncludeExcludeWaitingMessage = true; this.diffEditor.updateProperties({ contentLeft: os.EOL, contentRight: os.EOL, title: loc.diffEditorTitle }); this.differencesTable.selectedRows = null; if (this.tablelistenersToDispose) { this.tablelistenersToDispose.forEach(x => x.dispose()); } this.resetButtons(ResetButtonState.comparing); this.promise = this.execute(); await this.promise; } private createCompareButton(): void { this.compareButton = this.view.modelBuilder.button().withProps({ label: loc.compare, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'compare.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'compare-inverse.svg') }, title: loc.compare }).component(); this.compareButton.onDidClick(async (_click) => { await this.startCompare(); }); } private createCancelButton(): void { this.cancelCompareButton = this.view.modelBuilder.button().withProps({ label: loc.stop, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'stop.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'stop-inverse.svg') }, title: loc.stop }).component(); this.cancelCompareButton.onDidClick(async (_click) => { await this.cancelCompare(); }); } /** * Resets state of buttons and text to initial state before a comparison is started/completed */ public resetWindow(): void { // clean the pane this.flexModel.removeItem(this.loader); this.flexModel.removeItem(this.waitText); this.flexModel.addItem(this.startText, { CSSStyles: { 'margin': 'auto' } }); this.resetButtons(ResetButtonState.beforeCompareStart); } public async cancelCompare(): Promise { TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareCancelStarted') .withAdditionalProperties({ 'startTime': Date.now().toString(), 'operationId': this.operationId }).send(); this.resetWindow(); // cancel compare if (this.operationId) { const service = await this.getService(); const result = await service.schemaCompareCancel(this.operationId); if (!result || !result.success) { TelemetryReporter.createErrorEvent2(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareCancelFailed', undefined, undefined, getTelemetryErrorType(result.errorMessage)) .withAdditionalProperties({ 'operationId': this.operationId }).send(); vscode.window.showErrorMessage(loc.cancelErrorMessage(result.errorMessage)); } TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareCancelEnded') .withAdditionalProperties({ 'endTime': Date.now().toString(), 'operationId': this.operationId }).send(); } } private createGenerateScriptButton(): void { this.generateScriptButton = this.view.modelBuilder.button().withProps({ label: loc.generateScript, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'generate-script.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'generate-script-inverse.svg') }, }).component(); this.generateScriptButton.onDidClick(async (_click) => { await this.generateScript(); }); } public async generateScript(): Promise { TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareGenerateScriptStarted') .withAdditionalProperties({ 'startTime': Date.now().toString(), 'operationId': this.comparisonResult.operationId }).send(); const service = await this.getService(); const result = await service.schemaCompareGenerateScript(this.comparisonResult.operationId, this.targetEndpointInfo.serverName, this.targetEndpointInfo.databaseName, azdata.TaskExecutionMode.script); if (!result || !result.success) { TelemetryReporter.createErrorEvent2(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareGenerateScriptFailed', undefined, undefined, getTelemetryErrorType(result.errorMessage)) .withAdditionalProperties({ 'operationId': this.comparisonResult.operationId }).send(); vscode.window.showErrorMessage(loc.generateScriptErrorMessage(result.errorMessage)); } TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareGenerateScriptEnded') .withAdditionalProperties({ 'endTime': Date.now().toString(), 'operationId': this.comparisonResult.operationId }).send(); } private createOptionsButton() { this.optionsButton = this.view.modelBuilder.button().withProps({ label: loc.options, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'options.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'options-inverse.svg') }, title: loc.options }).component(); this.optionsButton.onDidClick(async (_click) => { TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareOptionsOpened'); // create fresh every time this.schemaCompareOptionDialog = new SchemaCompareOptionsDialog(this.deploymentOptions, this); this.schemaCompareOptionDialog.openDialog(); }); } private createApplyButton() { this.applyButton = this.view.modelBuilder.button().withProps({ label: loc.apply, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'start.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'start-inverse.svg') }, }).component(); this.applyButton.onDidClick(async (_click) => { await this.publishChanges(); }); } public async publishChanges(): Promise { // need only yes button - since the modal dialog has a default cancel const yesString = loc.YesButtonText; await vscode.window.showWarningMessage(loc.applyConfirmation, { modal: true }, yesString).then(async (result) => { if (result === yesString) { TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyStarted') .withAdditionalProperties({ 'startTime': Date.now().toString(), '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 this.setButtonsForRecompare(); const service: mssql.ISchemaCompareService = await this.getService(); let result: azdata.ResultStatus | undefined = undefined; switch (this.targetEndpointInfo.endpointType) { 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: result = await vscode.commands.executeCommand(loc.sqlDatabaseProjectsPublishChanges, this.comparisonResult.operationId, this.targetEndpointInfo.projectFilePath, this.targetEndpointInfo.extractTarget); 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)}`); } if (!result || !result.success || result.errorMessage) { TelemetryReporter.createErrorEvent2(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyFailed', undefined, undefined, getTelemetryErrorType(result?.errorMessage)) .withAdditionalProperties({ 'operationId': this.comparisonResult.operationId, 'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType) }).send(); vscode.window.showErrorMessage(loc.applyErrorMessage(result?.errorMessage)); // reenable generate script and apply buttons if apply failed this.generateScriptButton.enabled = true; this.generateScriptButton.title = loc.generateScriptEnabledMessage; this.applyButton.enabled = true; this.applyButton.title = loc.applyEnabledMessage; } else if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Project) { const workspaceApi = getDataWorkspaceExtensionApi(); workspaceApi.showProjectsView(); workspaceApi.refreshProjectsTree(); void vscode.window.showInformationMessage(loc.applySuccess); } TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareApplyEnded') .withAdditionalProperties({ 'endTime': Date.now().toString(), 'operationId': this.comparisonResult.operationId, 'targetType': getSchemaCompareEndpointString(this.targetEndpointInfo.endpointType) }).send(); } }); } private resetButtons(resetButtonState: ResetButtonState): void { switch (resetButtonState) { case (ResetButtonState.noSourceTarget): { this.compareButton.enabled = false; this.optionsButton.enabled = false; this.switchButton.enabled = ((this.sourceName && this.sourceName !== ' ') || (this.targetName && this.targetName !== ' ')) ? true : false; // allows switching if the source or target name is set this.openScmpButton.enabled = true; this.cancelCompareButton.enabled = false; this.selectSourceButton.enabled = true; this.selectTargetButton.enabled = true; break; } // Before start and after complete are same functionally. Adding two enum values for clarity. case (ResetButtonState.beforeCompareStart): case (ResetButtonState.afterCompareComplete): { this.compareButton.enabled = true; this.optionsButton.enabled = true; this.switchButton.enabled = true; this.openScmpButton.enabled = true; this.saveScmpButton.enabled = true; this.cancelCompareButton.enabled = false; this.selectSourceButton.enabled = true; this.selectTargetButton.enabled = true; break; } case (ResetButtonState.comparing): { this.compareButton.enabled = false; this.optionsButton.enabled = false; this.switchButton.enabled = false; this.openScmpButton.enabled = false; this.cancelCompareButton.enabled = true; this.selectSourceButton.enabled = false; this.selectTargetButton.enabled = false; break; } } // Set generate script and apply to false because specific values depend on result and are set separately this.generateScriptButton.enabled = false; this.applyButton.enabled = false; this.generateScriptButton.title = loc.generateScriptEnabledMessage; this.applyButton.title = loc.applyEnabledMessage; } public setButtonsForRecompare(): void { this.generateScriptButton.enabled = false; this.applyButton.enabled = false; this.generateScriptButton.title = loc.reCompareToRefeshMessage; this.applyButton.title = loc.reCompareToRefeshMessage; } // reset state afer loading an scmp private resetForNewCompare(): void { this.flexModel.removeItem(this.splitView); this.flexModel.removeItem(this.noDifferencesLabel); this.flexModel.addItem(this.startText, { CSSStyles: { 'margin': 'auto' } }); } private createSwitchButton(): void { this.switchButton = this.view.modelBuilder.button().withProps({ label: loc.switchDirection, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'switch-directions.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'switch-directions-inverse.svg') }, title: loc.switchDirectionDescription }).component(); this.switchButton.onDidClick(async (_click) => { TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareSwitch'); // switch source and target [this.sourceEndpointInfo, this.targetEndpointInfo] = [this.targetEndpointInfo, this.sourceEndpointInfo]; [this.sourceName, this.targetName] = [this.targetName, this.sourceName]; this.sourceNameComponent.updateProperties({ value: this.sourceName, title: this.sourceName }); this.targetNameComponent.updateProperties({ value: this.targetName, title: this.targetName }); // remember that source target have been toggled this.sourceTargetSwitched = this.sourceTargetSwitched ? false : true; // only compare if both source and target are set if (this.sourceEndpointInfo && this.targetEndpointInfo) { await this.startCompare(); } }); } private createSourceAndTargetButtons(): void { this.selectSourceButton = this.view.modelBuilder.button().withProps({ label: '•••', title: loc.selectSource, ariaLabel: loc.selectSource, secondary: true }).component(); this.selectSourceButton.onDidClick(async (_click) => { TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareSelectSource'); this.schemaCompareDialog = new SchemaCompareDialog(this, undefined, this.extensionContext); this.promise = this.schemaCompareDialog.openDialog(); await this.promise; }); this.selectTargetButton = this.view.modelBuilder.button().withProps({ label: '•••', title: loc.selectTarget, ariaLabel: loc.selectTarget, secondary: true }).component(); this.selectTargetButton.onDidClick(async (_click) => { TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareSelectTarget'); this.schemaCompareDialog = new SchemaCompareDialog(this, undefined, this.extensionContext); this.promise = await this.schemaCompareDialog.openDialog(); await this.promise; }); } private createOpenScmpButton() { this.openScmpButton = this.view.modelBuilder.button().withProps({ label: loc.openScmp, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'open-scmp.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'open-scmp-inverse.svg') }, title: loc.openScmpDescription }).component(); this.openScmpButton.onDidClick(async (_click) => { await this.openScmp(); }); } public async openScmp(): Promise { TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareOpenScmpStarted'); const rootPath = getRootPath(); let fileUris = await vscode.window.showOpenDialog( { canSelectFiles: true, canSelectFolders: false, canSelectMany: false, defaultUri: vscode.Uri.file(rootPath), openLabel: loc.open, filters: { 'scmp Files': ['scmp'], } } ); if (!fileUris || fileUris.length === 0) { return; } let fileUri = fileUris[0]; this.openScmpFile(fileUri, true); } /** * Primary functional entrypoint for opening the schema comparison window with the scmp file Uri provided. * @param fileUri .scmp file URI to open Schema Compare extension with * @param callFromWithinSC is the call from openScmp? False by default, since it is one of the direct entry points. */ public async openScmpFile(fileUri: vscode.Uri, callFromWithinSC: boolean = false): Promise { if (!callFromWithinSC) { //Instantiate and open schema compare window if called from "Open in Schema Compare" await this.launch(undefined, undefined, false, undefined); } const service = await this.getService(); let startTime = Date.now(); const result = await service.schemaCompareOpenScmp(fileUri.fsPath); if (!result || !result.success) { TelemetryReporter.sendErrorEvent2(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareOpenScmpFailed', undefined, undefined, getTelemetryErrorType(result.errorMessage)); vscode.window.showErrorMessage(loc.openScmpErrorMessage(result.errorMessage)); return; } this.sourceEndpointInfo = await this.constructEndpointInfo(result.sourceEndpointInfo, loc.sourceTitle); this.targetEndpointInfo = await this.constructEndpointInfo(result.targetEndpointInfo, loc.targetTitle); this.updateSourceAndTarget(); this.setDeploymentOptions(result.deploymentOptions); this.scmpSourceExcludes = result.excludedSourceElements; this.scmpTargetExcludes = result.excludedTargetElements; this.sourceTargetSwitched = result.originalTargetName !== this.targetEndpointInfo.databaseName; // clear out any old results this.resetForNewCompare(); TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareOpenScmpEnded') .withAdditionalProperties({ elapsedTime: (Date.now() - startTime).toString() }).send(); } private async constructEndpointInfo(endpoint: mssql.SchemaCompareEndpointInfo, caller: string): Promise { let ownerUri; let endpointInfo; if (endpoint && endpoint.endpointType === mssql.SchemaCompareEndpointType.Database) { // only set endpoint info if able to connect to the database ownerUri = await verifyConnectionAndGetOwnerUri(endpoint, caller); } if (ownerUri) { endpointInfo = endpoint; endpointInfo.ownerUri = ownerUri; } else if (endpoint.endpointType === mssql.SchemaCompareEndpointType.Project) { endpointInfo = { endpointType: endpoint.endpointType, packageFilePath: '', serverDisplayName: '', serverName: '', databaseName: '', ownerUri: '', connectionDetails: undefined, projectFilePath: endpoint.projectFilePath, targetScripts: [], dataSchemaProvider: endpoint.dataSchemaProvider, extractTarget: endpoint.extractTarget }; } else { // need to do this instead of just setting it to the endpoint because some fields are null which will cause an error when sending the compare request endpointInfo = { endpointType: endpoint.endpointType === mssql.SchemaCompareEndpointType.Database ? mssql.SchemaCompareEndpointType.Database : mssql.SchemaCompareEndpointType.Dacpac, serverDisplayName: '', serverName: '', databaseName: '', ownerUri: '', packageFilePath: endpoint.packageFilePath, connectionDetails: undefined }; } return endpointInfo; } private createSaveScmpButton(): void { this.saveScmpButton = this.view.modelBuilder.button().withProps({ label: loc.saveScmp, iconPath: { light: path.join(this.extensionContext.extensionPath, 'media', 'save-scmp.svg'), dark: path.join(this.extensionContext.extensionPath, 'media', 'save-scmp-inverse.svg') }, title: loc.saveScmpDescription, enabled: false }).component(); this.saveScmpButton.onDidClick(async (_click) => { await this.saveScmp(); }); } public async saveScmp(): Promise { const rootPath = getRootPath(); const filePath = await vscode.window.showSaveDialog( { defaultUri: vscode.Uri.file(rootPath), saveLabel: loc.save, filters: { 'scmp Files': ['scmp'], } } ); if (!filePath) { return; } // convert include/exclude maps to arrays of object ids let sourceExcludes: mssql.SchemaCompareObjectId[] = this.convertExcludesToObjectIds(this.originalSourceExcludes); let targetExcludes: mssql.SchemaCompareObjectId[] = this.convertExcludesToObjectIds(this.originalTargetExcludes); let startTime = Date.now(); TelemetryReporter.sendActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareSaveScmp'); const service = await this.getService(); const result = await service.schemaCompareSaveScmp(this.sourceEndpointInfo, this.targetEndpointInfo, azdata.TaskExecutionMode.execute, this.deploymentOptions, filePath.fsPath, sourceExcludes, targetExcludes); if (!result || !result.success) { TelemetryReporter.createErrorEvent2(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareSaveScmpFailed', undefined, undefined, getTelemetryErrorType(result.errorMessage)) .withAdditionalProperties({ operationId: this.comparisonResult.operationId }).send(); vscode.window.showErrorMessage(loc.saveScmpErrorMessage(result.errorMessage)); } TelemetryReporter.createActionEvent(TelemetryViews.SchemaCompareMainWindow, 'SchemaCompareSaveScmpEnded') .withAdditionalProperties({ elapsedTime: (Date.now() - startTime).toString(), operationId: this.comparisonResult.operationId }); } /** * Converts excluded diff entries into object ids which are needed to save them in an scmp */ private convertExcludesToObjectIds(excludedDiffEntries: Map): mssql.SchemaCompareObjectId[] { let result = []; excludedDiffEntries.forEach((value: mssql.DiffEntry) => { result.push({ nameParts: value.sourceValue ? value.sourceValue : value.targetValue, sqlObjectType: `Microsoft.Data.Tools.Schema.Sql.SchemaModel.${value.name}` }); }); return result; } private setButtonStatesForNoChanges(enableButtons: boolean): void { // generate script and apply can only be enabled if the target is a database or project if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Database || this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Project) { this.applyButton.enabled = enableButtons; this.applyButton.title = enableButtons ? loc.applyEnabledMessage : loc.applyNoChangesMessage; } if (this.targetEndpointInfo.endpointType === mssql.SchemaCompareEndpointType.Database) { this.generateScriptButton.enabled = enableButtons; this.generateScriptButton.title = enableButtons ? loc.generateScriptEnabledMessage : loc.generateScriptNoChangesMessage; } } private async getService(): Promise { if (this.schemaCompareService === null || this.schemaCompareService === undefined) { this.schemaCompareService = (await vscode.extensions.getExtension(mssql.extension.name).exports as mssql.IExtension).schemaCompare; } return this.schemaCompareService; } private async GetDefaultDeploymentOptions(): Promise { // Same as dacfx default options const service = await this.getService(); let result = await service.schemaCompareGetDefaultOptions(); this.setDeploymentOptions(result.defaultDeploymentOptions); } } // Borrowed as is from other extensions // TODO : figure out if any inbuilt alternative is available export function generateGuid(): string { let hexValues: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; // c.f. rfc4122 (UUID version 4 = xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) let oct: string = ''; let tmp: number; /* tslint:disable:no-bitwise */ for (let a: number = 0; a < 4; a++) { tmp = (4294967296 * Math.random()) | 0; oct += hexValues[tmp & 0xF] + hexValues[tmp >> 4 & 0xF] + hexValues[tmp >> 8 & 0xF] + hexValues[tmp >> 12 & 0xF] + hexValues[tmp >> 16 & 0xF] + hexValues[tmp >> 20 & 0xF] + hexValues[tmp >> 24 & 0xF] + hexValues[tmp >> 28 & 0xF]; } // 'Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively' let clockSequenceHi: string = hexValues[8 + (Math.random() * 4) | 0]; return oct.substr(0, 8) + '-' + oct.substr(9, 4) + '-4' + oct.substr(13, 3) + '-' + clockSequenceHi + oct.substr(16, 3) + '-' + oct.substr(19, 12); /* tslint:enable:no-bitwise */ }