Functions/Merge-Migration.ps1


function Merge-Migration
{
    <#
    .SYNOPSIS
    Creates a cumulative set of operations from migration scripts.
 
    .DESCRIPTION
    The `Merge-Migration` functions creates a cumulative set of migrations from migration scripts. If there are multiple operations across one or more migration scripts that touch the same database object, those changes are combined into one operation. For example, if you create a table in one migration, add a column in another migrations, then remove a column in a third migration, this function will output an operation that represents the final state for the object: a create table operation that includes the added column and doesn't include the removed column. In environments where tables are replicated, it is more efficient to modify objects once and have that change replicated once, than to have the same object modified multiple times and replicated multiple times.
 
    This function returns `Rivet.Migration` objects. Each object will have zero or more operations in its `PushOperations` property. If there are zero operations, it means the original operation was consolidated into another migration. Each operation has `Source` member on it, which is a list of all the migrations that contributed to that operation.
 
    .OUTPUTS
    Rivet.Migration
 
    .EXAMPLE
    Get-Migration | Merge-Migration
 
    Demonstrates how to run `Merge-Migration`. It is always used in conjunction with `Get-Migration`.
    #>

    [CmdletBinding()]
    [OutputType([Rivet.Migration])]
    param(
        [Parameter(ValueFromPipeline=$true)]
        [Rivet.Migration[]]
        # The path to the rivet.json file to use. By default, it will look in the current directory.
        $Migration
    )

    begin
    {
        Set-StrictMode -Version 'Latest'

        function Get-ColumnIndex
        {
            param(
                [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
                [string]
                # The column to check for.
                $Name,

                [Parameter(Mandatory=$true)]
                [Collections.Generic.List[Rivet.Column]]
                [AllowEmptyCollection()]
                # The column collection to modify
                $List
            )

            $columnIdx = $null
            for( $idx = 0; $idx -lt $List.Count; ++$idx )
            {
                if( $List[$idx].Name -eq $Name )
                {
                    return $idx
                }
            }
        }

        filter Add-Column
        {
            param(
                [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
                [Rivet.Column]
                # The column to check for.
                $Column,

                [Parameter(Mandatory=$true)]
                [Collections.Generic.List[Rivet.Column]]
                [AllowEmptyCollection()]
                # The column collection to modify
                $List,

                [Switch]
                # Replace only, don't add.
                $ReplaceOnly
            )

            $columnIdx = Get-ColumnIndex -Name $Column.Name -List $List
            if( $columnIdx -eq $null )
            {
                if( -not $ReplaceOnly )
                {
                    [void] $List.Add( $column )
                    return $true
                }
            }
            else
            {
                $null = $List.RemoveAt( $columnIdx )
                $List.Insert( $columnIdx, $column )
                return $true
            }

            return $false
        }

        filter Remove-Column
        {
            param(
                [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
                [string]
                # The column to check for.
                $Name,

                [Parameter(Mandatory=$true)]
                [Collections.Generic.List[Rivet.Column]]
                [AllowEmptyCollection()]
                # The column collection to modify
                $List
            )

            $columnIdx = Get-ColumnIndex -Name $Name -List $List
            if( $columnIdx -ne $null )
            {
                [void] $List.RemoveAt( $columnIdx )
                return $true
            }

            return $false
        }

        $databaseName = $null
        $migrations = New-Object 'Collections.Generic.List[Rivet.Migration]'
        [Collections.Generic.Hashset[string]]$preExistingObjects = $null
        [Collections.Generic.HashSet[string]]$newTables = $null
        [Collections.ArrayList]$operations = $null

        function Reset-OperationState
        {
            Set-Variable -Name 'newTables' -Scope 1 -Value (New-Object 'Collections.Generic.HashSet[string]')
            # list of every single operation we encounter across migrations
            Set-Variable -Name 'operations' -Scope 1 -Value (New-Object 'Collections.ArrayList')
            Set-Variable -Name 'preExistingObjects' -Scope 1 -Value (New-Object 'Collections.Generic.Hashset[string]')
        }
    }

    process
    {

        #$DebugPreference = 'Continue'

        foreach( $currentMigration in $Migration )
        {
            if( $databaseName -ne $Migration.Database )
            {
                Reset-OperationState
                $databaseName = $Migration.Database
            }

            function Register-Source
            {
                [CmdletBinding()]
                param(
                    [Rivet.Operation]
                    $Operation
                )
                    
                Set-StrictMode -Version 'Latest'

                #$DebugPreference = 'SilentlyContinue'
                Write-Debug -Message     ('Current Migration Name: <{0}>' -f $migrationName)
                Write-Debug -Message     ('Operation Type: <{0}>' -f $Operation.GetType().FullName)
                Write-Debug -Message     ('Start Source Count: <{0}>' -f $Operation.Source.Count)
                foreach( $source in $Operation.Source )
                {
                    Write-Debug -Message ('Source Migration Name: <{0}>' -f $source.FullName)
                    if( $source.FullName -eq $migrationName )
                    {
                        return
                    }
                }

                $Operation.Source.Add( $currentMigration )
                Write-Debug -Message     ('End Source Count: <{0}>' -f $Operation.Source.Count)
            }

            $migrationName = '{0}_{1}' -f $currentMigration.ID,$currentMigration.Name
            Write-Debug -Message ('{0}' -f $currentMigration.Path)

            $migrations.Add( $currentMigration )

            $pushOpIdx = 0
            for( $pushOpIdx = 0; $pushOpIdx -lt $currentMigration.PushOperations.Count; ++$pushOpIdx )
            {
                function Remove-CurrentOperation
                {
                    if( $pushOpIdx -ge $currentMigration.PushOperations.Count )
                    {
                        Write-Debug 'WTF?!'
                    }
                    $operation = $currentMigration.PushOperations[$pushOpIdx]
                    $currentMigration.PushOperations.RemoveAt( $pushOpIdx )
                    Set-Variable -Name 'pushOpIdx' -Scope 1 -Value ($pushOpIdx - 1)
                    Remove-Operation $operation
                }

                function Remove-Operation
                {
                    param(
                        $Operation
                    )

                    for( $idx = $operations.Count - 1; $idx -ge 0; --$idx )
                    {
                        if( $operations[$idx] -eq $Operation )
                        {
                            $operations.RemoveAt( $idx )
                            return
                        }
                    }
                }

                function Remove-OperationFromMigration
                {
                    param(
                        [Rivet.Operation]
                        $Operation
                    )

                    $opIdx = -1
                    $originalMigration = $Operation.Source[0]
                    $ops = $originalMigration.PushOperations
                    for( $idx = 0; $idx -lt $ops.Count; ++$idx )
                    {
                        if( $ops[$idx] -eq $Operation )
                        {
                            $opIdx = $idx
                        }
                    }
                    if( $opIdx -gt -1 )
                    {
                        $ops.RemoveAt( $opIdx )
                        if( $originalMigration -eq $currentMigration )
                        {
                            Set-Variable -Name 'pushOpIdx' -Scope 1 -Value ($pushOpIdx - 1)
                        }
                        Remove-Operation $Operation
                    }
                }

                $op = $currentMigration.PushOperations[$pushOpIdx]
                $previousObjectOps = $operations | 
                                        Where-Object { $_ } |
                                        Where-Object { Get-Member -InputObject $_ -Name 'ObjectName' } |
                                        Where-Object { Get-Member -InputObject $op -Name 'ObjectName' } |
                                        Where-Object { $_.ObjectName -eq $op.ObjectName }
                
                [void]$operations.Add( $op )
                $source = New-Object -TypeName 'Collections.Generic.List[Rivet.Migration]'
                $op | Add-Member -Name 'Source' -MemberType NoteProperty -Value $source
                Register-Source $op

                if( $op -is [Rivet.Operations.AddTableOperation] )
                {
                    [void]$newTables.Add( $op.ObjectName )
                    continue
                }

                if( $op -is [Rivet.Operations.RenameColumnOperation] )
                {
                    $tableName = '{0}.{1}' -f $op.SchemaName,$op.TableName
                    for( $idx = 0 ; $idx -lt $operations.Count; ++$idx )
                    {
                        $tableOp = $operations[$idx]
                        if( $tableOp -isnot [Rivet.Operations.AddTableOperation] )
                        {
                            continue
                        }

                        if( $tableOp.ObjectName -ne $tableName )
                        {
                            continue
                        }

                        $originalColumn = $tableOp.Columns | Where-Object { $_.Name -eq $op.Name }
                        if( $originalColumn )
                        {
                            $originalColumn.Name = $op.NewName
                            Register-Source $tableOp
                            Remove-CurrentOperation
                            continue
                        }
                    }
                    continue
                }

                if( $op -is [Rivet.Operations.RenameOperation] )
                {
                    $objectName = '{0}.{1}' -f $op.SchemaName,$op.Name
                    for( $idx = 0 ; $idx -lt $operations.Count; ++$idx )
                    {
                        $existingOp = $operations[$idx]
                        if( $existingOp -isnot [Rivet.Operations.AddTableOperation] )
                        {
                            continue
                        }

                        if( $existingOp.ObjectName -ne $objectName )
                        {
                            continue
                        }

                        $existingOp.Name = $op.NewName
                        Register-Source $existingOp
                        Remove-CurrentOperation
                        continue
                    }
                }

                if( $op -isnot [Rivet.Operations.ObjectOperation] )
                {
                    continue
                }

                $opTypeName = $op.GetType().Name
                $isRemoveOperation = $opTypeName -like 'Remove*' 

                # If the first action against this object is a removal
                if( $isRemoveOperation -and -not $previousObjectOps -and $op -is [Rivet.Operations.ObjectOperation] )
                {
                    [void]$preexistingObjects.Add( $op.ObjectName )
                }

                foreach( $existingOp in $previousObjectOps )
                {
                    if( $existingOp -eq $op )
                    {
                        continue
                    }

                    Register-Source $existingOp

                    if( $isRemoveOperation )
                    {
                        for( $idx = 0; $idx -lt $operations.Count; ++$idx )
                        {
                            if( $operations[$idx] -eq $existingOp )
                            {
                                $operations[$idx] = $null
                            }
                        }

                        $originalMigration = $existingOp.Source[0]
                        # Remove the original add operation from its migration
                        Remove-OperationFromMigration -Operation $existingOp

                        if( $op -is [Rivet.Operations.RemoveTableOperation] )
                        {
                            for( $idx = $operations.Count - 1; $idx -ge 0 ; --$idx )
                            {
                                $tableOp = $operations[$idx]
                                if( $tableOp -and ($tableOp | Get-Member 'ObjectName') )
                                {
                                    Write-Debug $tableOp.ObjectName
                                }
                                if( $tableOp -isnot [Rivet.Operations.TableObjectOperation] )
                                {
                                    continue
                                }

                                Write-Debug ' is a table object operation'
                                $tableOpTableName = '{0}.{1}' -f $tableOp.SchemaName,$tableOp.TableName
                                if( $op.ObjectName -eq $tableOpTableName )
                                {
                                    Remove-OperationFromMigration -Operation $tableOp
                                }
                            }
                        }

                        if( $existingOp -and -not $preexistingObjects.Contains($existingOp.ObjectName) )
                        {
                            Remove-CurrentOperation
                        }

                        continue
                    }
                    elseif( $opTypeName -eq 'UpdateTableOperation' )
                    {
                        if( $existingOp -is [Rivet.Operations.AddTableOperation] )
                        {
                            $op.AddColumns | Add-Column -List $existingOp.Columns | Out-Null
                            $op.UpdateColumns | Add-Column -List $existingOp.Columns | Out-Null
                            $op.RemoveColumns | Remove-Column -List $existingOp.Columns | Out-Null
                            Remove-CurrentOperation
                        }
                        elseif( $existingOp -is [Rivet.Operations.UpdateTableOperation] )
                        {
                            if( $op.AddColumns -and $op.AddColumns.Count -gt 0 )
                            {
                                # If adding a column that was previously removed, remove the removal
                                $op.AddColumns | 
                                    Select-Object -ExpandProperty 'Name' | 
                                    ForEach-Object {
                                        $columnName = $_
                                        $columnIdx = -1
                                        for( $idx = 0; $idx -lt $existingOp.RemoveColumns.Count; ++$idx )
                                        {
                                            if( $existingOp.RemoveColumns[$idx] -eq $columnName )
                                            {
                                                $columnIdx = $idx
                                                break
                                            }
                                        }
                                        if( $columnIdx -ge 0 )
                                        {
                                            $existingOp.RemoveColumns.RemoveAt( $columnIdx )
                                        }
                                    }

                                # Add new columns to the original operation
                                for( $colIdx = 0; $colIdx -lt $op.AddColumns.Count; ++$colIdx )
                                {
                                    Add-Column -Column $op.AddColumns[$colIdx] -List $existingOp.AddColumns | Out-Null
                                    $op.AddColumns.RemoveAt( $colIdx-- )
                                }
                            }

                            # Replace existing column definitions
                            for( $colIdx = 0; $colIdx -lt $op.UpdateColumns.Count; ++$colIdx )
                            {
                                $column = $op.UpdateColumns[$colIdx]
                                $moved = Add-Column -Column $column -List $existingOp.AddColumns -ReplaceOnly
                                $moved = $moved -or (Add-Column -Column $column -List $existingOp.UpdateColumns)
                                if( $moved )
                                {
                                    $op.UpdateColumns.RemoveAt( $colIdx-- )
                                }
                            }

                            # Remove columns
                            for( $colIdx = 0; $colIdx -lt $op.RemoveColumns.Count; ++$colIdx )
                            {
                                $columnName = $op.RemoveColumns[$colIdx]
                                # Remove a column we previously added
                                $removedFromAddedColumns = Remove-Column -List $existingOp.AddColumns -Name $columnName
                                $removedFromUpdatedColumns = Remove-Column -List $existingOp.UpdateColumns -Name $columnName

                                $op.RemoveColumns.RemoveAt( $colIdx-- )
                                if( -not ($removedFromAddedColumns -or $removedFromUpdatedColumns) )
                                {
                                    [void] $existingOp.RemoveColumns.Add( $columnName )
                                }
                            }

                            if( -not $op.ToQuery() )
                            {
                                Remove-CurrentOperation
                            }

                            if( -not $existingOp.ToQuery() )
                            {
                                Remove-OperationFromMigration -Operation $existingOp
                            }
                        }
                        else
                        {
                            Write-Error ('Unhandled operation of type ''{0}''.' -f $existingOp.GetType())
                        }                        
                        continue
                    }
                }
            } 
        }
    }

    end
    {
        $migrations.ToArray()
    }
}