Skip to content

Commit 321ef38

Browse files
Compare-DbaDbSchema - Add new command for schema comparison via sqlpackage
Implements Compare-DbaDbSchema using sqlpackage's DeployReport action to compare a source DACPAC against a target database or DACPAC file, returning structured schema difference objects. Closes #5342 (do Compare-DbaDbSchema) Co-authored-by: Andreas Jordan <andreasjordan@users.noreply.github.com>
1 parent 97d03be commit 321ef38

4 files changed

Lines changed: 388 additions & 0 deletions

File tree

dbatools.psd1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
'Clear-DbaLatchStatistics',
8181
'Clear-DbaPlanCache',
8282
'Clear-DbaWaitStatistics',
83+
'Compare-DbaDbSchema',
8384
'Compare-DbaAgReplicaAgentJob',
8485
'Compare-DbaAgReplicaCredential',
8586
'Compare-DbaAgReplicaLogin',

dbatools.psm1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ if ($PSVersionTable.PSVersion.Major -lt 5) {
456456
'Remove-DbaBackup',
457457
'Get-DbaPermission',
458458
'Get-DbaLastBackup',
459+
'Compare-DbaDbSchema',
459460
'Compare-DbaAgReplicaAgentJob',
460461
'Compare-DbaAgReplicaCredential',
461462
'Compare-DbaAgReplicaLogin',

public/Compare-DbaDbSchema.ps1

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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

Comments
 (0)