|
| 1 | +function Compare-DbaDbSchema { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Compares the schema of a DACPAC file against a target database or DACPAC file using sqlpackage. |
| 5 | +
|
| 6 | + .DESCRIPTION |
| 7 | + Uses sqlpackage's DeployReport action to compare a source DACPAC against a target (live database or DACPAC file) and returns a structured list of schema differences. |
| 8 | +
|
| 9 | + The source must be a DACPAC file. The target can be either a live SQL Server database or another DACPAC file. |
| 10 | +
|
| 11 | + Note: Comparing two live databases is not supported by sqlpackage. To compare two live databases, first export one as a DACPAC using Export-DbaDacPackage, then pass that DACPAC as the source to this command. |
| 12 | +
|
| 13 | + sqlpackage must be available. Install it via Install-DbaSqlPackage if needed. |
| 14 | +
|
| 15 | + .PARAMETER SourcePath |
| 16 | + The path to the source DACPAC file to compare from. |
| 17 | +
|
| 18 | + .PARAMETER TargetSqlInstance |
| 19 | + The target SQL Server instance containing the database to compare against. |
| 20 | +
|
| 21 | + .PARAMETER TargetSqlCredential |
| 22 | + Login to the target instance using alternative credentials. Accepts PowerShell credentials (Get-Credential). |
| 23 | +
|
| 24 | + Only SQL authentication is supported. When not specified, uses Trusted Authentication. |
| 25 | +
|
| 26 | + .PARAMETER TargetDatabase |
| 27 | + The name of the target database on the target SQL Server instance to compare against. |
| 28 | +
|
| 29 | + .PARAMETER TargetPath |
| 30 | + The path to the target DACPAC file to compare against. Use this for offline comparisons between two DACPAC files. |
| 31 | +
|
| 32 | + .PARAMETER OutputPath |
| 33 | + The directory where the XML deployment report will be saved. Defaults to the configured DbatoolsExport path. |
| 34 | +
|
| 35 | + The report file is removed after parsing unless -KeepReport is specified. |
| 36 | +
|
| 37 | + .PARAMETER KeepReport |
| 38 | + When specified, the generated XML deployment report file is kept after parsing. By default, the file is removed after processing. |
| 39 | +
|
| 40 | + .PARAMETER EnableException |
| 41 | + By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message. |
| 42 | + This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting. |
| 43 | + Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch. |
| 44 | +
|
| 45 | + .NOTES |
| 46 | + Tags: Dacpac, Schema, SqlPackage, Compare, Deployment |
| 47 | + Author: the dbatools team + Claude |
| 48 | +
|
| 49 | + Website: https://dbatools.io |
| 50 | + Copyright: (c) 2018 by dbatools, licensed under MIT |
| 51 | + License: MIT https://opensource.org/licenses/MIT |
| 52 | +
|
| 53 | + Requires sqlpackage to be installed. Use Install-DbaSqlPackage to install it. |
| 54 | +
|
| 55 | + .LINK |
| 56 | + https://dbatools.io/Compare-DbaDbSchema |
| 57 | +
|
| 58 | + .OUTPUTS |
| 59 | + PSCustomObject |
| 60 | +
|
| 61 | + Returns one object per schema difference found between source and target. |
| 62 | +
|
| 63 | + Properties: |
| 64 | + - SourcePath: Full path to the source DACPAC file |
| 65 | + - Target: The target database or DACPAC path |
| 66 | + - Operation: The type of change (e.g., Create, Alter, Drop, Rename) |
| 67 | + - Value: The schema object name (e.g., [dbo].[MyTable]) |
| 68 | + - Type: The object type (e.g., Table, Procedure, View) |
| 69 | + - ReportPath: Full path to the XML deployment report (only present when -KeepReport is specified) |
| 70 | +
|
| 71 | + .EXAMPLE |
| 72 | + PS C:\> Compare-DbaDbSchema -SourcePath C:\temp\source.dacpac -TargetSqlInstance sql2019 -TargetDatabase AdventureWorks |
| 73 | +
|
| 74 | + Compares the source.dacpac schema against the AdventureWorks database on sql2019 and returns a list of differences. |
| 75 | +
|
| 76 | + .EXAMPLE |
| 77 | + PS C:\> Compare-DbaDbSchema -SourcePath C:\temp\v2.dacpac -TargetPath C:\temp\v1.dacpac |
| 78 | +
|
| 79 | + Compares two DACPAC files offline and returns the schema differences. |
| 80 | +
|
| 81 | + .EXAMPLE |
| 82 | + PS C:\> Export-DbaDacPackage -SqlInstance sql2016 -Database db_source -FilePath C:\temp\db_source.dacpac |
| 83 | + PS C:\> Compare-DbaDbSchema -SourcePath C:\temp\db_source.dacpac -TargetSqlInstance sql2016 -TargetDatabase db_target |
| 84 | +
|
| 85 | + Exports a DACPAC from the source database, then compares it against the target database on the same instance. |
| 86 | +
|
| 87 | + .EXAMPLE |
| 88 | + PS C:\> Compare-DbaDbSchema -SourcePath C:\temp\source.dacpac -TargetSqlInstance sql2019 -TargetDatabase AdventureWorks -KeepReport -OutputPath C:\reports |
| 89 | +
|
| 90 | + Compares schema and keeps the XML report file in C:\reports. |
| 91 | + #> |
| 92 | + [CmdletBinding()] |
| 93 | + param ( |
| 94 | + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] |
| 95 | + [Alias("Path", "FilePath")] |
| 96 | + [string]$SourcePath, |
| 97 | + [DbaInstance]$TargetSqlInstance, |
| 98 | + [PSCredential]$TargetSqlCredential, |
| 99 | + [string]$TargetDatabase, |
| 100 | + [string]$TargetPath, |
| 101 | + [string]$OutputPath = (Get-DbatoolsConfigValue -FullName "Path.DbatoolsExport"), |
| 102 | + [switch]$KeepReport, |
| 103 | + [switch]$EnableException |
| 104 | + ) |
| 105 | + |
| 106 | + begin { |
| 107 | + $sqlPackagePath = Get-DbaSqlPackagePath -EnableException:$EnableException |
| 108 | + if (-not $sqlPackagePath) { |
| 109 | + return |
| 110 | + } |
| 111 | + |
| 112 | + if (-not (Test-Path -Path $SourcePath)) { |
| 113 | + Stop-Function -Message "Source DACPAC file not found: $SourcePath" |
| 114 | + return |
| 115 | + } |
| 116 | + |
| 117 | + if ((Test-Bound -Not -ParameterName TargetSqlInstance) -and (Test-Bound -Not -ParameterName TargetPath)) { |
| 118 | + Stop-Function -Message "You must specify either -TargetSqlInstance (with -TargetDatabase) or -TargetPath." |
| 119 | + return |
| 120 | + } |
| 121 | + |
| 122 | + if (Test-Bound -ParameterName TargetSqlInstance) { |
| 123 | + if (Test-Bound -Not -ParameterName TargetDatabase) { |
| 124 | + Stop-Function -Message "When using -TargetSqlInstance you must also specify -TargetDatabase." |
| 125 | + return |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + if (Test-Bound -ParameterName TargetPath) { |
| 130 | + if (-not (Test-Path -Path $TargetPath)) { |
| 131 | + Stop-Function -Message "Target DACPAC file not found: $TargetPath" |
| 132 | + return |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + $null = Test-ExportDirectory -Path $OutputPath |
| 137 | + } |
| 138 | + |
| 139 | + process { |
| 140 | + if (Test-FunctionInterrupt) { return } |
| 141 | + |
| 142 | + $timeStamp = (Get-Date).ToString("yyMMdd_HHmmss_f") |
| 143 | + $reportFile = Join-Path -Path $OutputPath -ChildPath "Compare-DbaDbSchema_$timeStamp.xml" |
| 144 | + |
| 145 | + # Build sqlpackage arguments |
| 146 | + $sqlPackageArgs = "/action:deployreport /of:True /sf:""$SourcePath"" /op:""$reportFile""" |
| 147 | + |
| 148 | + if (Test-Bound -ParameterName TargetSqlInstance) { |
| 149 | + try { |
| 150 | + $targetServer = Connect-DbaInstance -SqlInstance $TargetSqlInstance -SqlCredential $TargetSqlCredential |
| 151 | + } catch { |
| 152 | + Stop-Function -Message "Failure connecting to $TargetSqlInstance" -Category ConnectionError -ErrorRecord $_ -Target $TargetSqlInstance |
| 153 | + return |
| 154 | + } |
| 155 | + |
| 156 | + $connString = $targetServer.ConnectionContext.ConnectionString | Convert-ConnectionString |
| 157 | + if ($connString -notmatch "Database=") { |
| 158 | + $connString = "$connString;Database=$TargetDatabase" |
| 159 | + } |
| 160 | + $connStringEscaped = $connString.Replace('"', "'") |
| 161 | + $sqlPackageArgs += " /tcs:""$connStringEscaped""" |
| 162 | + $targetDescription = "$($targetServer.DomainInstanceName)\$TargetDatabase" |
| 163 | + } else { |
| 164 | + $sqlPackageArgs += " /tf:""$TargetPath""" |
| 165 | + $targetDescription = $TargetPath |
| 166 | + } |
| 167 | + |
| 168 | + Write-Message -Level Verbose -Message "Running sqlpackage with args: $sqlPackageArgs" |
| 169 | + |
| 170 | + try { |
| 171 | + $startInfo = New-Object System.Diagnostics.ProcessStartInfo |
| 172 | + $startInfo.FileName = $sqlPackagePath |
| 173 | + $startInfo.Arguments = $sqlPackageArgs |
| 174 | + $startInfo.RedirectStandardError = $true |
| 175 | + $startInfo.RedirectStandardOutput = $true |
| 176 | + $startInfo.UseShellExecute = $false |
| 177 | + $startInfo.CreateNoWindow = $true |
| 178 | + |
| 179 | + $process = New-Object System.Diagnostics.Process |
| 180 | + $process.StartInfo = $startInfo |
| 181 | + $process.Start() | Out-Null |
| 182 | + $stdout = $process.StandardOutput.ReadToEnd() |
| 183 | + $stderr = $process.StandardError.ReadToEnd() |
| 184 | + $process.WaitForExit() |
| 185 | + |
| 186 | + Write-Message -Level Verbose -Message "sqlpackage stdout: $stdout" |
| 187 | + |
| 188 | + if ($process.ExitCode -ne 0) { |
| 189 | + Stop-Function -Message "sqlpackage failed: $stderr" -Target $SourcePath |
| 190 | + return |
| 191 | + } |
| 192 | + } catch { |
| 193 | + Stop-Function -Message "Failed to run sqlpackage" -ErrorRecord $_ -Target $SourcePath |
| 194 | + return |
| 195 | + } |
| 196 | + |
| 197 | + if (-not (Test-Path -Path $reportFile)) { |
| 198 | + Stop-Function -Message "sqlpackage did not produce an output report at $reportFile. Output: $stdout" |
| 199 | + return |
| 200 | + } |
| 201 | + |
| 202 | + # Parse the deployment report XML |
| 203 | + try { |
| 204 | + [xml]$report = Get-Content -Path $reportFile -ErrorAction Stop |
| 205 | + } catch { |
| 206 | + Stop-Function -Message "Failed to read or parse the deployment report at $reportFile" -ErrorRecord $_ -Target $reportFile |
| 207 | + return |
| 208 | + } |
| 209 | + |
| 210 | + $sourcePathFull = (Resolve-Path -Path $SourcePath).Path |
| 211 | + |
| 212 | + foreach ($operation in $report.DeploymentReport.Operations.Operation) { |
| 213 | + $operationName = $operation.Name |
| 214 | + foreach ($item in $operation.Item) { |
| 215 | + $outputObject = [PSCustomObject]@{ |
| 216 | + SourcePath = $sourcePathFull |
| 217 | + Target = $targetDescription |
| 218 | + Operation = $operationName |
| 219 | + Value = $item.Value |
| 220 | + Type = $item.Type |
| 221 | + } |
| 222 | + |
| 223 | + if ($KeepReport) { |
| 224 | + $outputObject | Add-Member -NotePropertyName "ReportPath" -NotePropertyValue $reportFile |
| 225 | + } |
| 226 | + |
| 227 | + $outputObject |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + if (-not $KeepReport) { |
| 232 | + Remove-Item -Path $reportFile -ErrorAction SilentlyContinue |
| 233 | + } else { |
| 234 | + Write-Message -Level Verbose -Message "Deployment report kept at $reportFile" |
| 235 | + } |
| 236 | + } |
| 237 | +} |
0 commit comments