functions/Invoke-DBSafeShrink.ps1

function Invoke-DBSafeShrink {
    <#
    .SYNOPSIS
        Shrinks a Sql Server mdf database file without fragmenting the indexes.
 
    .DESCRIPTION
        Shrinks a Sql Server mdf database file without fragmenting the indexes. Can be
        used to migrate indexes to a new filegroup, or just shrink and move the indexes
        back to the original filegroup after the shrink is done. Typically runs faster than
        a normal shrink operation.
 
        IMPORTANT: The second file that gets created will match the used size of the original
        filegroup. You must have enough disk space to support this.
 
        Wrote this after I read this post by Paul Randal:
            https://www.sqlskills.com/blogs/paul/why-you-should-not-shrink-your-data-files/
 
        I always knew shrinking was very bad, but until I read these comments by
        Paul my brain never clicked that there could be a better way:
 
        QUOTE (Paul Randal):
            The method I like to recommend is as follows:
 
            - Create a new filegroup
            - Move all affected tables and indexes into the new filegroup using the
                CREATE INDEX … WITH (DROP_EXISTING = ON) ON syntax, to move the tables
                and remove fragmentation from them at the same time
            - Drop the old filegroup that you were going to shrink anyway (or
                shrink it way down if its the primary filegroup)
            - Move the indexes back to the original filegroup if desired (added by me :))
 
        This script automates those steps so you don't have to.
 
    .PARAMETER ServerInstance
        The sql server instance to connect to.
 
    .PARAMETER Databases
        The databases to shrink. A string array.
 
    .PARAMETER Credentials
        Specifies credentials to connect to the database with. If not supplied then a trusted connection will be used.
 
    .PARAMETER FileGroupName
        The file group name to shrink. Defaults to PRIMARY. It does not matter if there are
        multiple mdf or ldf files assigned.
 
    .PARAMETER NewFileDirectory
        If passed, then this will be the directory that the new temporary file will be created in.
        Otherwise it will default to the same directory as the primary file. This directory will
        be created if it does not exist. If it already exists, then nothing happens. If the path
        is a local path, then the directory will be created on the server using xp_create_sub directory.
 
        NOTES:
            - The drive must exist, else an exception will occur
            - The SQL Server account must have write access to the target folder, else an exception will occur
 
    .PARAMETER Direction
        If the direction is twoway then the the indexes are moved to the temporary file and back
        after the original file is shrunk. If the direction is oneway, then the indexes are moved
        to the temporary file, and the process will be complete.
 
    .PARAMETER Online
        Specifies whether underlying tables and associated indexes are available for queries and data
        modification during the index operation. The default is OFF.
 
        .PARAMETER AdjustRecovery
        If this switch is enabled then the recovery model of the database will be temporarily changed
        to SIMPLE, then put back to the original recovery model. If the switch is missing, then the
        recovery model will not be changed.
 
    .PARAMETER ShrinkTimeout
        If the original requires shrinking in a twoway operation, then the shrinks will occur
        in very small chunks at a time. This timeout will control how long that operation can
        run before timing out.
 
        NOTES: This timeout is in minutes.
 
    .PARAMETER ShrinkIncrementMB
        The amount of MB to shrink the file each shrink attempt. If left as the default of 0 then
        a simple formula will adjust the shrink increment based upon the file size.
 
    .PARAMETER IndexMoveTimeout
        The amount of time that controls how long a index move can run before timing out.
 
        NOTES: This timeout is in minutes.
 
    .PARAMETER MinimumFreeSpaceMB
        The file shrunk must have at least this amount of free space, otherwise the shrink
        operation will write out a warning and skip the shrink operation for this file.
 
    .PARAMETER TLogBackupJobName
        The name of a TLOG back up job name. If passed in, then the job will be temporarily
        disabled until the process finishes as TLOG backups will interfere with the file operations.
         The job will be re-enabled once the process finishes.
 
    .INPUTS
        None. You cannot pipe objects to this script.
 
    .OUTPUTS
        Generates a table of records detailing before and after sizes for each filegroup shrunk.
 
    .EXAMPLE
        PS> Invoke-DBSafeShrink -ServerInstance "servername" -Databases "AdventureWorks2008","AdventureWorks2012"
 
    .EXAMPLE
        PS> Invoke-DBSafeShrink -ServerInstance "servername" -Databases "AdventureWorks2008","AdventureWorks2012" -MinimumFreeSpaceMB 1 -NewFileDirectory "D:\sqltemp\"
 
    .LINK
        https://github.com/tcartwright/tcdbtools
 
    .NOTES
        Author: Tim Cartwright
 
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$ServerInstance,
        [Parameter(Mandatory=$true)]
        [ValidateCount(1, 9999)]
        [string[]]$Databases,
        [pscredential]$Credentials,
        [string]$FileGroupName = "PRIMARY",
        [System.IO.DirectoryInfo]$NewFileDirectory,
        [ValidateSet("oneway", "twoway")]
        [string]$Direction = "twoway",
        [switch]$Online,
        [switch]$AdjustRecovery,
        [int]$ShrinkTimeout = 5,
        [ValidateRange(0, 20000)]
        [int]$ShrinkIncrementMB = 0,
        [int]$IndexMoveTimeout = 5,
        [int]$MinimumFreeSpaceMB = 250,
        [string]$TLogBackupJobName
    )

    begin {
        $SqlCmdArguments = New-DBSqlCmdArguments -ServerInstance $ServerInstance -Credentials $Credentials
        $server = New-DBSMOServer -ServerInstance $ServerInstance -Credentials $Credentials

        # these two function can show you what fields are available
        # $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.Table], $true)
        # $server.GetDefaultInitFields([Microsoft.SqlServer.Management.Smo.Table])
        # cut way down on the number of table fields smo pulls to hopefully speed it up
        [string[]]$fields = @("ID", "FileGroup", "HasClusteredIndex")
        $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.Table], $fields)

        $shrinkTimeOut = ([Timespan]::FromMinutes($ShrinkTimeout).TotalSeconds)
        $IndexMoveTimeout = ([Timespan]::FromMinutes($IndexMoveTimeout).TotalSeconds)
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        $swFormat = "hh\:mm\:ss"

        <#
        # IF THEY PASSED IN A NEW DIRECTORY, MAKE SURE IT IS CREATED
        #>

        if ($NewFileDirectory) {
            try {
                Write-Information "[$($sw.Elapsed.ToString($swFormat))] CREATING DIRECTORY ($($NewFileDirectory.FullName))"
                CreateNewDirectory -NewFileDirectory $NewFileDirectory -SqlCmdArguments $SqlCmdArguments
            } catch {
                throw
                return
            }
        }

        Write-InformationColorized "[$($sw.Elapsed.ToString($swFormat))] STARTING" -ForegroundColor Yellow

        <#
        # IF THEY PASSED IN A TLOG BACKUP JOB NAME THEN STOP IT, AND WAIT A BIT FOR IT TO FINISH
        #>

        if ($TLogBackupJobName) {
            StopTLogBackupJob -SqlCmdArguments $SqlCmdArguments -TLogBackupJobName $TLogBackupJobName
        }

        $ret = @{}
        if ($AdjustRecovery.IsPresent) {
            $recoveryModels = AdjustRecoveryModels -AdjustRecovery $AdjustRecovery -SqlCmdArguments $SqlCmdArguments -Databases $Databases -recoveryModels @{} -TargetRecoveryModel "SIMPLE"
        }

        # if they passed in ALL_USER_DATABASES get all database names
        $Databases = Get-AllUserDatabases -Databases $Databases -SqlCmdArguments $SqlCmdArguments
    }

    process {
        foreach($Database in $Databases) {
            $SqlCmdArguments.Database = $Database
            $db = $server.Databases[$Database]

            # try to enum the tables to hopefully speed this up
            $db.EnumObjects([Microsoft.SqlServer.Management.Smo.DatabaseObjectTypes]::Table) | Out-Null

            if ($db.Name -ne $Database) {
                Write-Warning "Can't find the database [$Database] in '$ServerInstance'"
                continue
            };

            $originalFG = $db.FileGroups | Where-Object { $_.Name -ieq $fileGroupName } | Select-Object -First 1
            if (-not $originalFG) {
                Write-Warning "Filegroup [$fileGroupName] not found in database: [$Database]"
                continue
            }

            $freeSpace = GetFreeSpace -SqlCmdArguments $SqlCmdArguments -Database $Database -FileGroupName $FileGroupName | Where-Object { $_.used_space_mb -gt 0 }
            $totals = $freeSpace | Measure-Object -Property free_space_mb, used_space_mb -Sum

            if ($totals[0].Sum -lt $MinimumFreeSpaceMB ) {
                Write-Warning "Database [$Database] does not have any files with the minimum required free space of $MinimumFreeSpaceMB MB for this operation to continue."
                continue
            }

            $usedTotalSize = $totals[1].Sum
            # in case of a restart, figure out the average without counting the shrink temp file in the divisor
            $averageUsedSize = $totals[1].Sum / ([System.Object[]]($freeSpace | Where-Object { $_.filegroup_name -ine "SHRINK_DATA_TEMP" })).Count

            foreach ($fs in $freeSpace) {
                $fileInfo = [PSCustomObject] @{
                    Database = $Database
                    FileGroupName = [string]$fs.filegroup_name
                    FileName = [string]$fs.file_name
                    SizeBefore = [int]$fs.current_size_mb
                    UsedBefore = [int]$fs.used_space_mb
                    FreeBefore = [int]$fs.free_space_mb
                    SizeAfter  = [int]0
                    UsedAfter  = [int]0
                    FreeAfter  = [int]0
                }

                $ret.Add("$Database-$($fileInfo.FileName)", $fileInfo) | Out-Null
            }

            $originalFile = $originalFG.Files | Where-Object { $_.IsPrimaryFile } | Select-Object -First 1
            # capture this before moving any indexes as the values will be different after
            $originalFiles = $originalFG.Files | ForEach-Object {
                [PSCustomObject] @{
                    Database = $Database
                    Name = [string]$_.Name
                    Size = $ret["$Database-$($_.Name)"].SizeBefore
                }
            }

            $fi = [System.IO.FileInfo]$originalFile.FileName
            $newFileName = "$($fi.DirectoryName)\$($fi.BaseName)_SHRINK_DATA_TEMP$($fi.Extension)"

            if ($NewFileDirectory) {
                $newFileName = [System.IO.Path]::Combine($NewFileDirectory.FullName, ([System.IO.FileInfo]$newFileName).Name)
            }

            # if they point to a local drive lets check free space, if unc then hopefully it succeeds
            if ([IO.Path]::IsPathRooted($newFileName)) {
                $pathRoot = [IO.Path]::GetPathRoot($newFileName) -replace ":\\", ""
                $drives = Invoke-Sqlcmd @SqlCmdArguments -query "EXEC xp_fixeddrives" -Encrypt Optional

                $drive = $drives | Where-Object { $_.drive -imatch $pathRoot }

                if ($drive) {
                    $mbFree = $drive."MB free"

                    if ($usedTotalSize -ge ($mbFree - 10)) {
                        Write-Error "Drive $($pathRoot):\ ($mbFree MB) does not have enough space to create a file of size $usedTotalSize MB"
                        continue
                    }
                }
            }

            Write-InformationColorized "[$($sw.Elapsed.ToString($swFormat))] SHRINKING SERVER: $ServerInstance, DATABASE: $Database, FILEGROUP: $fileGroupName" -ForegroundColor Cyan

            <#
            # SETUP THE NEW FILEGROUP AND FILE, BACKUP OPERATIONS CAN CONFLICT, ITS BEST TO STOP BACK JOBS AHEAD OF TIME
            #>

            try {
                AddTempFileGroupAndFile -SqlCmdArguments $SqlCmdArguments -NewFileName $newFileName -Size $usedTotalSize -OriginalFile $originalFile
            } catch {
                throw
                continue
            }

            <#
            # MOVE THE INDEXES FROM THE BASE FILEGROUP TO THE TARGET TEMP FILEGROUP
            #>

            MoveIndexes -db $db -fromFG $fileGroupName -toFG "SHRINK_DATA_TEMP" -indicator "-->" -timeout $IndexMoveTimeout -SqlCmdArguments $SqlCmdArguments -Online:$Online.IsPresent

            <#
            # MOVE THE INDEXES BACK TO THE ORIGINAL FILEGROUP IF THE DIRECTION IS TWOWAY, AND REMOVE THE TEMP FILEGROUP AND FILE
            #>

            if ($direction -ieq "twoway") {
                <#
                # SHRINK THE OLD FILE GROUP DOWN A SMALL AMOUNT AT A TIME UNTIL WE REACH THE SMALLEST SIZE
                #>

                Write-InformationColorized "[$($sw.Elapsed.ToString($swFormat))] SHRINKING FILES IN FG $fileGroupName" -ForegroundColor Magenta
                foreach($file in $originalFiles) {
                    # shrink each file a percentage at a time to keep from possibly timing out the shrink. cause even EMPTY files take a long time to shrink. WTF.
                    $fileName = $file.Name
                    [int]$size = $file.Size

                    Write-Verbose "LOOPING SHRINKFILE"
                    $size = ShrinkFile -SqlCmdArguments $SqlCmdArguments -size $size -fileName $fileName -targetSizeMB $averageUsedSize -timeout $ShrinkTimeout -ShrinkIncrementMB $ShrinkIncrementMB | Select-Object -Last 1
                }
                Write-InformationColorized "[$($sw.Elapsed.ToString($swFormat))] FINISHED SHRINKING FILES IN FG $fileGroupName" -ForegroundColor Magenta

                MoveIndexes -db $db -fromFG "SHRINK_DATA_TEMP" -toFG $fileGroupName -indicator "<--" -timeout $IndexMoveTimeout -SqlCmdArguments $SqlCmdArguments -Online:$Online.IsPresent

                RemoveTempFileGroupAndFile -SqlCmdArguments $SqlCmdArguments -shrinkTimeOut $shrinkTimeOut
            }

            <#
            # PERFORM ONE LAST TRUNCATEONLY SHRINK
            #>

            Write-Information "[$($sw.Elapsed.ToString($swFormat))] SHRINKING FILES IN FG [$fileGroupName] WITH TRUNCATEONLY"
            foreach($file in $originalFiles) {
                $fileName = $file.Name
                $sql = "DBCC SHRINKFILE([$fileName], TRUNCATEONLY) WITH NO_INFOMSGS"
                Write-Verbose "$sql"
                Invoke-Sqlcmd @SqlCmdArguments -Query $sql -QueryTimeout $shrinkTimeOut -Encrypt Optional | Format-Table
            }

            <#
            # RECORD THE CHANGES AFTER THE OPERATION HAS COMPLETED FOR THE FILES
            #>

            $freeSpace = GetFreeSpace -SqlCmdArguments $SqlCmdArguments -Database $Database -FileGroupName $FileGroupName
            $freeSpace | ForEach-Object {
                $obj = $ret["$Database-$($_.file_name)"]
                if ($obj) {
                    $obj.SizeAfter = [int]$_.current_size_mb
                    $obj.UsedAfter = [int]$_.used_space_mb
                    $obj.FreeAfter = [int]$_.free_space_mb
                }
            }
            Write-InformationColorized "[$($sw.Elapsed.ToString($swFormat))] FINISHED SHRINKING SERVER: $ServerInstance, DATABASE: $Database, FILEGROUP: $fileGroupName" -ForegroundColor Cyan
        }
    }

    end {
        # don't pass the target recovery model in so that the function will reset the name tag to the original
        if ($AdjustRecovery.IsPresent) {
            AdjustRecoveryModels -AdjustRecovery $AdjustRecovery -SqlCmdArguments $SqlCmdArguments -Databases $Databases -recoveryModels $recoveryModels -TargetRecoveryModel $null | Out-Null
        }

        if ($TLogBackupJobName) {
            $sql = "EXEC msdb.dbo.sp_update_job @job_name = N'$TLogBackupJobName', @enabled = 1 ;"
            Write-Information "[$($sw.Elapsed.ToString($swFormat))] ENABLING JOB [$TLogBackupJobName]"
            Write-Verbose $sql
            Invoke-Sqlcmd @SqlCmdArguments -query $sql -Encrypt Optional
        }

        Write-InformationColorized "[$($sw.Elapsed.ToString($swFormat))] FINISHED" -ForegroundColor Yellow
        $sw.Stop()

        return $ret.Values
    }
}