diff --git a/functions/Find-DbaOrphanedFile.ps1 b/functions/Find-DbaOrphanedFile.ps1 index 63c7e435d9..77445a6860 100644 --- a/functions/Find-DbaOrphanedFile.ps1 +++ b/functions/Find-DbaOrphanedFile.ps1 @@ -37,6 +37,9 @@ function Find-DbaOrphanedFile { This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting. Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch. + .PARAMETER Recurse + If this switch is enabled, the command will search subdirectories of the Path parameter. + .NOTES Tags: Orphan, Database, DatabaseFile Author: Sander Stad (@sqlstad), sqlstad.nl @@ -67,6 +70,11 @@ function Find-DbaOrphanedFile { Finds the orphaned files in "E:\Dir1" and "E:Dir2" in addition to the default directories. + .EXAMPLE + PS C:\> Find-DbaOrphanedFile -SqlInstance sql2014 -Path 'E:\Dir1' -Recurse + + Finds the orphaned files in "E:\Dir1" and any of its subdirectories in addition to the default directories. + .EXAMPLE PS C:\> Find-DbaOrphanedFile -SqlInstance sql2014 -LocalOnly @@ -81,55 +89,85 @@ function Find-DbaOrphanedFile { PS C:\> Find-DbaOrphanedFile -SqlInstance sql2014, sql2016 -FileType fsf, mld Finds the orphaned ending with ".fsf" and ".mld" in addition to the default filetypes ".mdf", ".ldf", ".ndf" for both the servers sql2014 and sql2016. - #> - [CmdletBinding()] + + [CmdletBinding(DefaultParameterSetName = 'LocalOnly')] + param ( - [parameter(Mandatory, ValueFromPipeline)] + [Parameter(Mandatory, ValueFromPipeline)] [DbaInstanceParameter[]]$SqlInstance, [pscredential]$SqlCredential, [string[]]$Path, [string[]]$FileType, - [switch]$LocalOnly, - [switch]$RemoteOnly, - [switch]$EnableException + [Parameter(ParameterSetName = 'LocalOnly')][switch]$LocalOnly, + [Parameter(ParameterSetName = 'RemoteOnly')][switch]$RemoteOnly, + [switch]$EnableException, + [switch]$Recurse ) begin { function Get-SQLDirTreeQuery { - param($PathList) - # use sysaltfiles in lower versions - - $q1 = "CREATE TABLE #enum ( id int IDENTITY, fs_filename nvarchar(512), depth int, is_file int, parent nvarchar(512) ); DECLARE @dir nvarchar(512);" - $q2 = "SET @dir = 'dirname'; + param([object[]]$SqlPathList, [object[]]$UserPathList, $FileTypes, $SystemFiles, [Switch]$Recurse) + + $q1 = " + CREATE TABLE #enum ( + id int IDENTITY + , fs_filename nvarchar(512) + , depth int + , is_file int + , parent nvarchar(512) + , parent_id int + ); + DECLARE @dir nvarchar(512); + " + + $q2 = " + SET @dir = 'dirname'; INSERT INTO #enum( fs_filename, depth, is_file ) - EXEC xp_dirtree @dir, 1, 1; + EXEC xp_dirtree @dir, recurse, 1; UPDATE #enum SET parent = @dir, - fs_filename = ltrim(rtrim(fs_filename)) - WHERE parent IS NULL;" - - $query_files_sql = "SELECT e.fs_filename AS filename, e.parent - FROM #enum AS e - WHERE e.fs_filename NOT IN( 'xtp', '5', '`$FSLOG', '`$HKv2', 'filestream.hdr' ) - AND is_file = 1;" + fs_filename = ltrim(rtrim(e.fs_filename)), + parent_id = (SELECT MAX(i.id) FROM #enum i WHERE i.id < e.id AND i.depth = e.depth-1 AND i.is_file = 0) + FROM #enum e + WHERE e.parent IS NULL; + " + + $query_files_sql = " + SELECT e.fs_filename AS filename, e.parent + FROM #enum AS e + WHERE e.fs_filename NOT IN( 'xtp', '5', '`$FSLOG', '`$HKv2', 'filestream.hdr', '" + $($SystemFiles -join "','") + "' ) + AND CASE + WHEN e.fs_filename LIKE '%.%' + THEN REVERSE(LEFT(REVERSE(e.fs_filename), CHARINDEX('.', REVERSE(e.fs_filename)) - 1)) + ELSE '' + END IN('" + $($FileTypes -join "','") + "') + AND e.is_file = 1; + " # build the query string based on how many directories they want to enumerate $sql = $q1 - $sql += $($PathList | Where-Object { $_ -ne '' } | ForEach-Object { "$([System.Environment]::Newline)$($q2 -Replace 'dirname', $_)" }) + $sql += $($SqlPathList | Where-Object { $_ -ne '' } | ForEach-Object { "$([System.Environment]::Newline)$($q2.Replace('dirname',$_).Replace('recurse','1'))" } ) + If ($UserPathList) { + $recurseVal = If ($Recurse) { '0' } Else { '1' } + $sql += $($UserPathList | Where-Object { $_ -ne '' } | ForEach-Object { "$([System.Environment]::Newline)$($q2.Replace('dirname',$_).Replace('recurse',$recurseVal))" } ) + } $sql += $query_files_sql Write-Message -Level Debug -Message $sql return $sql } + function Get-SqlFileStructure { param ( [Parameter(Mandatory, Position = 1)] [Microsoft.SqlServer.Management.Smo.SqlSmoObject]$smoserver ) - if ($smoserver.versionMajor -eq 8) { + + # use sysaltfiles in lower versions + if ($smoserver.VersionMajor -eq 8) { $sql = "select filename from sysaltfiles" } else { $sql = "select physical_name as filename from sys.master_files" @@ -141,20 +179,17 @@ function Find-DbaOrphanedFile { # Add support for Full Text Catalogs in Sql Server 2005 and below if ($server.VersionMajor -lt 10) { - $databaselist = $smoserver.Databases | Select-Object -property Name, IsFullTextEnabled - foreach ($db in $databaselist) { - if ($db.IsFullTextEnabled -eq $false) { - continue - } - $database = $db.name + $databaselist = $smoserver.Databases | Select-Object -Property Name, IsFullTextEnabled + foreach ($db in $databaselist | Where-Object IsFullTextEnabled) { + $database = $db.Name $fttable = $null = $smoserver.Databases[$database].ExecuteWithResults('sp_help_fulltext_catalogs') - foreach ($ftc in $fttable.Tables[0].rows) { - $null = $ftfiletable.Rows.add($ftc.Path) + foreach ($ftc in $fttable.Tables[0].Rows) { + $null = $ftfiletable.Rows.Add($ftc.Path) } } } - $null = $dbfiletable.Tables.Add($ftfiletable) + return $dbfiletable.Tables.Filename } @@ -167,51 +202,59 @@ function Find-DbaOrphanedFile { return $path } - $FileType += "mdf", "ldf", "ndf" $systemfiles = "distmdl.ldf", "distmdl.mdf", "mssqlsystemresource.ldf", "mssqlsystemresource.mdf", "model_msdbdata.mdf", "model_msdblog.ldf", "model_replicatedmaster.mdf", "model_replicatedmaster.ldf" - $FileTypeComparison = $FileType | ForEach-Object { $_.ToLowerInvariant() } | Where-Object { $_ } | Sort-Object | Get-Unique + $FileType += "mdf", "ldf", "ndf" + $fileTypeComparison = $FileType | ForEach-Object { $_.ToLowerInvariant() } | Where-Object { $_ } | Sort-Object -Unique } process { foreach ($instance in $SqlInstance) { + + # Connect to the instance try { $server = Connect-SqlInstance -SqlInstance $instance -SqlCredential $sqlcredential } catch { Stop-Function -Message "Error occurred while establishing connection to $instance" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue } + # Reset all the arrays - $dirtreefiles = $valid = $paths = $matching = @() + $sqlpaths = $userpaths = $matching = $valid = @() + $dirtreefiles = @{ } - $filestructure = Get-SqlFileStructure $server + # Gather a list of files known to SQL Server + $sqlfiles = Get-SqlFileStructure $server - # Get any paths associated with current data and log files - foreach ($file in $filestructure) { - $paths += Split-Path -Path $file -Parent + # Get the parent directories of those files + $sqlfiles | ForEach-Object { + $sqlpaths += Split-Path -Path $_ -Parent } - # Get the default data and log directories from the instance + # Include the default data and log directories from the instance Write-Message -Level Debug -Message "Adding paths" - $paths += $server.RootDirectory + "\DATA" - $paths += Get-SqlDefaultPaths $server data - $paths += Get-SqlDefaultPaths $server log - $paths += $server.MasterDBPath - $paths += $server.MasterDBLogPath - $paths += $Path - $paths = $paths | ForEach-Object { "$_".TrimEnd("\") } | Sort-Object | Get-Unique - $sql = Get-SQLDirTreeQuery $paths - $datatable = $server.Databases['master'].ExecuteWithResults($sql).Tables[0] - - foreach ($row in $datatable) { - $fullpath = [IO.Path]::combine($row.parent, $row.filename) - $dirtreefiles += [pscustomobject]@{ + $sqlpaths += "$($server.RootDirectory)\DATA" + $sqlpaths += Get-SqlDefaultPaths $server data + $sqlpaths += Get-SqlDefaultPaths $server log + $sqlpaths += $server.MasterDBPath + $sqlpaths += $server.MasterDBLogPath + + # Gather a list of files from the filesystem + $sqlpaths = $sqlpaths | ForEach-Object { $_.TrimEnd("\") } | Sort-Object -Unique + if ($Path) { + $userpaths = $Path | ForEach-Object { $_.TrimEnd("\") } | Sort-Object -Unique + } + $sql = Get-SQLDirTreeQuery -SqlPathList $sqlpaths -UserPathList $userpaths -FileTypes $fileTypeComparison -SystemFiles $systemfiles -Recurse:$Recurse + $dirtreefiles = $server.Databases['master'].ExecuteWithResults($sql).Tables[0] | ForEach-Object { + $fullpath = [IO.Path]::combine($_.parent, $_.filename) + [PSCustomObject]@{ FullPath = $fullpath Comparison = [IO.Path]::GetFullPath($(Format-Path $fullpath)) } } + # Output files in the dirtree not known to SQL Server $dirtreefiles = $dirtreefiles | Where-Object { $_ } | Sort-Object Comparison -Unique - foreach ($file in $filestructure) { + foreach ($file in $sqlfiles) { $valid += [IO.Path]::GetFullPath($(Format-Path $file)) } @@ -228,9 +271,8 @@ function Find-DbaOrphanedFile { $dirtreematcher = @{ } foreach ($el in $dirtreefiles) { - $dirtreematcher[$el.Comparison] = $el.Fullpath + $dirtreematcher[$el.Comparison] = $el.FullPath } - foreach ($file in $matching) { if ($file -notin $valid) { $fullpath = $dirtreematcher[$file] diff --git a/tests/Find-DbaOrphanedFile.Tests.ps1 b/tests/Find-DbaOrphanedFile.Tests.ps1 index 9d23a55ba5..ae35c90428 100644 --- a/tests/Find-DbaOrphanedFile.Tests.ps1 +++ b/tests/Find-DbaOrphanedFile.Tests.ps1 @@ -5,10 +5,10 @@ Write-Host -Object "Running $PSCommandPath" -ForegroundColor Cyan Describe "$CommandName Unit Tests" -Tag 'UnitTests' { Context "Validate parameters" { [object[]]$params = (Get-Command $CommandName).Parameters.Keys | Where-Object {$_ -notin ('whatif', 'confirm')} - [object[]]$knownParameters = 'SqlInstance', 'SqlCredential', 'Path', 'FileType', 'LocalOnly', 'RemoteOnly', 'EnableException' + [object[]]$knownParameters = 'SqlInstance', 'SqlCredential', 'Path', 'FileType', 'LocalOnly', 'RemoteOnly', 'Recurse', 'EnableException' $knownParameters += [System.Management.Automation.PSCmdlet]::CommonParameters It "Should only contain our specific parameters" { - (@(Compare-Object -ReferenceObject ($knownParameters | Where-Object {$_}) -DifferenceObject $params).Count ) | Should Be 0 + (@(Compare-Object -ReferenceObject ($knownParameters | Where-Object {$_}) -DifferenceObject $params).Count ) | Should -Be 0 } } } @@ -19,6 +19,12 @@ Describe "$CommandName Integration Tests" -Tags "IntegrationTests" { $dbname = "dbatoolsci_orphanedfile" $server = Connect-DbaInstance -SqlInstance $script:instance2 $null = $server.Query("CREATE DATABASE $dbname") + $tmpdir = "c:\temp\orphan_$(Get-Random)" + if (-not(Test-Path $tmpdir)) { + $null = New-Item -Path $tmpdir -type Container + } + $tmpdirInner = Join-Path $tmpdir "inner" + $null = New-Item -Path $tmpdirInner -type Container $result = Get-DbaDatabase -SqlInstance $script:instance2 -Database $dbname if ($result.count -eq 0) { it "has failed setup" { @@ -29,28 +35,38 @@ Describe "$CommandName Integration Tests" -Tags "IntegrationTests" { } AfterAll { Get-DbaDatabase -SqlInstance $script:instance2 -Database $dbname | Remove-DbaDatabase -Confirm:$false - } - $null = Detach-DbaDatabase -SqlInstance $script:instance2 -Database $dbname -Force - $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 - - It "Has the correct default properties" { - $ExpectedStdProps = 'ComputerName,InstanceName,SqlInstance,Filename,RemoteFilename'.Split(',') - ($results[0].PSStandardMembers.DefaultDisplayPropertySet.ReferencedPropertyNames | Sort-Object) | Should Be ($ExpectedStdProps | Sort-Object) + Remove-Item $tmpdir -Recurse -Force -ErrorAction SilentlyContinue } It "Has the correct properties" { + $null = Detach-DbaDatabase -SqlInstance $script:instance2 -Database $dbname -Force + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 + $ExpectedStdProps = 'ComputerName,InstanceName,SqlInstance,Filename,RemoteFilename'.Split(',') + ($results[0].PSStandardMembers.DefaultDisplayPropertySet.ReferencedPropertyNames | Sort-Object) | Should -Be ($ExpectedStdProps | Sort-Object) $ExpectedProps = 'ComputerName,InstanceName,SqlInstance,Filename,RemoteFilename,Server'.Split(',') - ($results[0].PsObject.Properties.Name | Sort-Object) | Should Be ($ExpectedProps | Sort-Object) + ($results[0].PsObject.Properties.Name | Sort-Object) | Should -Be ($ExpectedProps | Sort-Object) } + It "Finds two files" { - $results.Count | Should Be 2 + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 + $results.Filename.Count | Should -Be 2 } - $results.FileName | Remove-Item - - $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 It "Finds zero files after cleaning up" { - $results.Count | Should Be 0 + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 + $results.FileName | Remove-Item + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 + $results.Filename.Count | Should -Be 0 + } + It "works with -Recurse" { + "a" | out-file (Join-Path $tmpdir "out.mdf") + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 -Path $tmpdir + $results.Filename.Count | Should -Be 1 + move-item "$tmpdir\out.mdf" -destination $tmpdirInner + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 -Path $tmpdir + $results.Filename.Count | Should -Be 0 + $results = Find-DbaOrphanedFile -SqlInstance $script:instance2 -Path $tmpdir -Recurse + $results.Filename.Count | Should -Be 1 } } } \ No newline at end of file