Functions/Update-Database.ps1


function Update-Database
{
    <#
    .SYNOPSIS
    Applies a set of migrations to the database.
     
    .DESCRIPTION
    By default, applies all unapplied migrations to the database. You can reverse all migrations with the `Down` switch.
     
    .EXAMPLE
    Update-Database -Path C:\Projects\Rivet\Databases\Rivet\Migrations
     
    Applies all un-applied migrations from the `C:\Projects\Rivet\Databases\Rivet\Migrations` directory.
     
    .EXAMPLE
    Update-Database -Path C:\Projects\Rivet\Databases\Rivet\Migrations -Pop
     
    Reverses all migrations in the `C:\Projects\Rivet\Databases\Rivet\Migrations` directory
    #>

    [CmdletBinding(DefaultParameterSetName='Push', SupportsShouldProcess=$True)]
    param(
        [Parameter(Mandatory=$true)]
        [Rivet.Configuration.Configuration]
        $Configuration,

        [Parameter(Mandatory=$true)]
        [string[]]
        # The path to the migration.
        $Path,

        [Parameter(Mandatory=$true,ParameterSetName='Pop')]
        [Parameter(Mandatory=$true,ParameterSetName='PopByName')]
        [Parameter(Mandatory=$true,ParameterSetName='PopByCount')]
        [Parameter(Mandatory=$true,ParameterSetName='PopAll')]
        [Switch]
        # Reverse the given migration(s).
        $Pop,

        [Parameter(ParameterSetName='Push')]
        [Parameter(Mandatory=$true,ParameterSetName='PopByName')]
        [string[]]
        $Name,

        [Parameter(Mandatory=$true,ParameterSetName='PopByCount')]
        [UInt32]
        # Reverse the given migration(s).
        $Count,

        [Parameter(Mandatory=$true,ParameterSetName='PopAll')]
        [Switch]
        # Reverse the given migration(s).
        $All,

        [Switch]
        # Running internal Rivet migrations. This is for internal use only. If you use this flag, Rivet will break when you upgrade. You've been warned!
        $RivetSchema,

        [Parameter(ParameterSetName='Pop')]
        [Parameter(ParameterSetName='PopByCount')]
        [Parameter(ParameterSetName='PopByName')]
        [Parameter(ParameterSetName='PopAll')]
        [Switch]
        # Force popping a migration you didn't apply or that is old.
        $Force
    )

    Set-StrictMode -Version 'Latest'

    function ConvertTo-RelativeTime
    {
        param(
            [Parameter(Mandatory=$true)]
            [DateTime]
            # The date time to convert to a relative time string.
            $DateTime
        )

        [TimeSpan]$howLongAgo = (Get-Date) - $DateTime
        $howLongAgoMsg = New-Object 'Text.StringBuilder'
        if( $howLongAgo.Days )
        {
            [void] $howLongAgoMsg.AppendFormat('{0} day', $howLongAgo.Days)
            if( $howLongAgo.Days -ne 1 )
            {
                [void] $howLongAgoMsg.Append('s')
            }
            [void] $howLongAgoMsg.Append(', ')
        }

        if( $howLongAgo.Days -or $howLongAgo.Hours )
        {
            [void] $howLongAgoMsg.AppendFormat('{0} hour', $howLongAgo.Hours)
            if( $howLongAgo.Hours -ne 1 )
            {
                [void] $howLongAgoMsg.Append('s')
            }
            [void] $howLongAgoMsg.Append(', ')
        }

        if( $howLongAgo.Days -or $howLongAgo.Hours -or $howLongAgo.Minutes )
        {
            [void] $howLongAgoMsg.AppendFormat('{0} minute', $howLongAgo.Minutes)
            if( $howLongAgo.Minutes -ne 1 )
            {
                [void] $howLongAgoMsg.Append('s')
            }
            [void] $howLongAgoMsg.Append(', ')
        }

        [void] $howLongAgoMsg.AppendFormat('{0} second', $howLongAgo.Seconds)
        if( $howLongAgo.Minutes -ne 1 )
        {
            [void] $howLongAgoMsg.Append('s')
        }

        [void] $howLongAgoMsg.Append( ' ago' )

        return $howLongAgoMsg.ToString()
    }

    $stopMigrating = $false
    
    $popping = ($PSCmdlet.ParameterSetName -like 'Pop*')
    $numPopped = 0

    $who = ('{0}\{1}' -f $env:USERDOMAIN,$env:USERNAME);

    #$matchedNames = @{ }
    $byName = @{ }
    if( $PSBoundParameters.ContainsKey('Name') )
    {
        $byName['Include'] = $Name
    }

    Get-MigrationFile -Path $Path -Configuration $Configuration @byName -ErrorAction Stop |
        Sort-Object -Property 'MigrationID' -Descending:$popping |
        Where-Object {

            if( $RivetSchema )
            {
                return $true
            }

            if( [int64]$_.MigrationID -lt 1000000000000 )
            {
                Write-Error ('Migration ''{0}'' has an invalid ID. IDs lower than 01000000000000 are reserved for internal use.' -f $_.FullName)
                $stopMigrating = $true
                return $false
            }
            return $true
        } |
        Where-Object { 
            $migration = $null
            $preErrorCount = $Global:Error.Count
            try
            {
                $migration = Test-Migration -ID $_.MigrationID -PassThru #-ErrorAction Ignore
            }
            catch
            {
                $errorCount = $Global:Error.Count - $preErrorCount
                for( $idx = 0; $idx -lt $errorCount; ++$idx )
                {
                    $Global:Error.RemoveAt(0)
                }
            }

            if( $popping )
            {
                if( $PSCmdlet.ParameterSetName -eq 'PopByCount' -and $numPopped -ge $Count )
                {
                    return $false
                }
                $numPopped++

                $youngerThan = ((Get-Date).ToUniversalTime()) - (New-TimeSpan -Minutes 20)
                if( $migration -and ($migration.Who -ne $who -or $migration.AtUtc -lt $youngerThan) )
                {
                    $howLongAgo = ConvertTo-RelativeTime -DateTime ($migration.AtUtc.ToLocalTime())
                    $confirmQuery = "Are you sure you want to pop migration {0} from database {1} on {2} applied by {3} {4}?" -f $_.FullName,$Connection.Database,$Connection.DataSource,$migration.Who,$howLongAgo
                    $confirmCaption = "Pop Migration {0}?" -f $_.FullName
                    if( -not $Force -and -not $PSCmdlet.ShouldContinue( $confirmQuery, $confirmCaption ) )
                    {
                        return $false
                    }
                }
                $migration
            }
            else
            {
                -not ($migration)
            }
        } |
        ForEach-Object {
            if( $stopMigrating )
            {
                return
            }
            else
            {
                return $_
            }
        } |
        Convert-FileInfoToMigration -Configuration $Configuration |
        ForEach-Object {
            $migrationInfo = $_
            $migrationInfo.DataSource = $Connection.DataSource

            try
            {
                $Connection.Transaction = $Connection.BeginTransaction()

                if( $Pop )
                {
                    $operations = $migrationInfo.PopOperations
                    $action = 'Pop'
                    $sprocName = 'RemoveMigration'
                }
                else
                {
                    $operations = $migrationInfo.PushOperations
                    $action = 'Push'
                    $sprocName = 'InsertMigration'
                }

                if( -not $operations.Count )
                {
                    Write-Error ('{0} migration''s {1}-Migration function is empty.' -f $migrationInfo.FullName,$action)
                    return
                }

                $operations | Invoke-MigrationOperation -Migration $migrationInfo

                $query = 'exec [rivet].[{0}] @ID = @ID, @Name = @Name, @Who = @Who, @ComputerName = @ComputerName' -f $sprocName
                $parameters = @{
                                    ID = [int64]$migrationInfo.ID; 
                                    Name = $migrationInfo.Name;
                                    Who = $who;
                                    ComputerName = $env:COMPUTERNAME;
                                }
                Invoke-Query -Query $query -NonQuery -Parameter $parameters  | Out-Null

                $target = '{0}.{1}' -f $Connection.DataSource,$Connection.Database
                $operation = '{0} migration {1} {2}' -f $PSCmdlet.ParameterSetName,$migrationInfo.ID,$migrationInfo.Name
                if ($PSCmdlet.ShouldProcess($target, $operation))
                {
                    $Connection.Transaction.Commit()
                }
                else 
                {
                    $stopMigrating = $true
                    $Connection.Transaction.Rollback()
                }
            }
            catch
            {
                $Connection.Transaction.Rollback()
            
                $stopMigrating = $true
            
                # TODO: Create custom exception for migration query errors so that we can report here when unknown things happen.
                if( $_.Exception -isnot [ApplicationException] )
                {
                    Write-RivetError -Message ('Migration {0} failed' -f $migrationInfo.Path) -CategoryInfo $_.CategoryInfo.Category -ErrorID $_.FullyQualifiedErrorID -Exception $_.Exception -CallStack ($_.ScriptStackTrace)
                }            
            }
            finally
            {
                $Connection.Transaction = $null
            }
        }
}