functions/repair-d365bacpacmodelfile.ps1


<#
    .SYNOPSIS
        Repair a bacpac model file
         
    .DESCRIPTION
        As the backend of the Azure SQL infrastructure keeps evolving, the bacpac file can contain invalid instructions while we are trying to import into a local SQL Server installation on a Tier1 environment
         
    .PARAMETER Path
        Path to the bacpac model file that you want to work against
         
    .PARAMETER OutputPath
        Path to where the repaired model file should be placed
         
        The default value is going to create a file next to the Path (input) file, with the '-edited' name appended to it
         
    .PARAMETER PathRepairSimple
        Path to the json file, that contains all the instructions to be executed in the "Simple" section
         
        The default json file is part of the module, and can be located with the below command:
        explorer.exe $(Join-Path -Path $(Split-Path -Path (Get-Module d365fo.tools -ListAvailable)[0].Path -Parent) -ChildPath "internal\misc")
        - Look for the "RepairBacpac.Simple.json" file
         
        Or you can see the latest version, online, inside the github repository: https://github.com/d365collaborative/d365fo.tools/tree/master/d365fo.tools/internal/misc/RepairBacpac.Simple.json
         
        Simple means, that we can remove complex elements, based on some basic logic. E.g.
         
        {
        "Search": "*<Element Type=\"SqlPermissionStatement\"*ms_db_configreader*",
        "End": "*</Element>*"
        }
         
        "*<Element Type=\"SqlPermissionStatement\"*ms_db_configreader*" can identify below, and together with "*</Element>*" - we know when to stop.
         
        <Element Type="SqlPermissionStatement" Name="[Grant.Delete.Object].[ms_db_configreader].[dbo].[dbo].[AutotuneBase]">
        <Property Name="Permission" Value="4" />
        <Relationship Name="Grantee">
        <Entry>
        <References Name="[ms_db_configreader]" />
        </Entry>
        </Relationship>
        <Relationship Name="Grantor">
        <Entry>
        <References ExternalSource="BuiltIns" Name="[dbo]" />
        </Entry>
        </Relationship>
        <Relationship Name="SecuredObject">
        <Entry>
        <References Name="[dbo].[AutotuneBase]" />
        </Entry>
        </Relationship>
        </Element>
         
    .PARAMETER PathRepairQualifier
        Path to the json file, that contains all the instructions to be executed in the "Qualifier" section
         
        The default json file is part of the module, and can be located with the below command:
        explorer.exe $(Join-Path -Path $(Split-Path -Path (Get-Module d365fo.tools -ListAvailable)[0].Path -Parent) -ChildPath "internal\misc")
        - Look for the "RepairBacpac.Qualifier.json" file
         
        Or you can see the latest version, online, inside the github repository: https://github.com/d365collaborative/d365fo.tools/tree/master/d365fo.tools/internal/misc/RepairBacpac.Qualifier.json
         
        Qualifier means, that we can remove complex elements, based on some basic logic. E.g.
         
        {
        "Search": "*<Element Type=\"SqlRoleMembership\">*",
        "Qualifier": "*<References Name=*ms_db_configwriter*",
        "End": "*</Element>*"
        }
         
        "*<Element Type=\"SqlRoleMembership\">*" can identify below, "*<References Name=*ms_db_configwriter*" qualifies that we are locating the correct one and together with "*</Element>*" - we know when to stop.
         
        <Element Type="SqlRoleMembership">
        <Relationship Name="Member">
        <Entry>
        <References Name="[ms_db_configwriter]" />
        </Entry>
        </Relationship>
        <Relationship Name="Role">
        <Entry>
        <References ExternalSource="BuiltIns" Name="[db_ddladmin]" />
        </Entry>
        </Relationship>
        </Element>
         
    .PARAMETER PathRepairReplace
        Path to the json file, that contains all the instructions to be executed in the "Replace" section
         
        The default json file is part of the module, and can be located with the below command:
        explorer.exe $(Join-Path -Path $(Split-Path -Path (Get-Module d365fo.tools -ListAvailable)[0].Path -Parent) -ChildPath "internal\misc")
        - Look for the "RepairBacpac.Replace.json" file
         
        Or you can see the latest version, online, inside the github repository: https://github.com/d365collaborative/d365fo.tools/tree/master/d365fo.tools/internal/misc/RepairBacpac.Replace.json
         
        Replace means, that we can replace/remove strings, based on some basic logic. E.g.
         
        {
        "Search": "<Property Name=\"AutoDrop\" Value=\"True\" />",
        "Replace": ""
        }
         
        "<Property Name=\"AutoDrop\" Value=\"True\" />" can identify below, and "" is the value we want to replace with it.
         
        <Property Name="AutoDrop" Value="True" />
         
    .PARAMETER KeepFiles
        Instruct the cmdlet to keep the files from the repair process
         
        The files are very large, so only use this as a way to analyze why your model file didn't end up in the desired state
         
        Use it while you evolve/develop your instructions, but remove it from ANY full automation scripts
         
    .PARAMETER Force
        Instruct the cmdlet to overwrite the file specified in the OutputPath if it already exists
         
    .EXAMPLE
        PS C:\> Repair-D365BacpacModelFile -Path C:\Temp\Base.xml -PathRepairSimple '' -PathRepairQualifier '' -PathRepairReplace 'C:\Temp\RepairBacpac.Replace.Custom.json'
         
        This will only process the Replace section, as the other repair paths are empty - indicating to skip them.
        It will load the instructions from the 'C:\Temp\RepairBacpac.Replace.Custom.json' file and run those in the Replace section.
         
    .EXAMPLE
        PS C:\> Repair-D365BacpacModelFile -Path C:\Temp\Base.xml -KeepFiles -Force
         
        This will process all repair sections.
        It will keep the files in the temporary work directory, for the user to analyze the files further.
        It will Force overwrite the output file, if it exists already.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
        Author: Florian Hopfner (@FH-Inway)
         
        Json files has to be an array directly in the root of the file. All " (double quotes) has to be escaped with \" - otherwise it will not work as intended.
         
        This cmdlet is inspired by the work of "Brad Bateman" (github: @batetech)
         
        His github profile can be found here:
        https://github.com/batetech
         
        Florian Hopfner did a gist implementation, which has been used as the foundation for this implementation
         
        The original gist is: https://gist.github.com/FH-Inway/f485c720b43b72bffaca5fb6c094707e
         
        His github profile can be found here:
        https://github.com/FH-Inway
#>

function Repair-D365BacpacModelFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $Path,

        [string] $OutputPath,

        [string] $PathRepairSimple = "$script:ModuleRoot\internal\misc\RepairBacpac.Simple.json",

        [string] $PathRepairQualifier = "$script:ModuleRoot\internal\misc\RepairBacpac.Qualifier.json",

        [string] $PathRepairReplace = "$script:ModuleRoot\internal\misc\RepairBacpac.Replace.json",

        [switch] $KeepFiles,

        [switch] $Force

    )
    begin {
        Invoke-TimeSignal -Start

        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        if (Test-PSFFunctionInterrupt) { return }

        if ([string]::IsNullOrEmpty($OutputPath)) {
            $OutputPath = $Path.Replace([System.IO.Path]::GetExtension($path), "-edited$([System.IO.Path]::GetExtension($path))")
        }

        if (-not $Force) {
            if (-not (Test-PathExists -Path $OutputPath -Type Leaf -ShouldNotExist)) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$OutputPath</c> already exists. Consider changing the <c='em'>OutputPath</c> or set the <c='em'>Force</c> parameter to overwrite the file."
                Stop-PSFFunction -Message "Stopping because output path was already present."
                return
            }
        }

        if (Test-PSFFunctionInterrupt) { return }
    }
    
    end {
        if (Test-PSFFunctionInterrupt) { return }

        # Create a local working directory, in the temporary directory
        $directoryObj = New-Item -Path "$([System.IO.Path]::GetTempPath())$((New-Guid).Guid)" -ItemType Directory -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
    
        if ($KeepFiles) {
            Write-PSFMessage -Level Host -Message "The working directory used for this repair is:`r`n<c='em'>$($directoryObj.FullName)</c>`r`n - Please only use the KeepFiles when needed."
        }

        # Path to help us keep track of the file and what changes have been made - troubleshooting is easier with this one
        $localInput = Join-Path -Path $directoryObj.FullName -ChildPath "raw.simple&replace.input.xml"
        $forOutput = Join-Path -Path $directoryObj.FullName -ChildPath "0.simple&replace.output.xml"

        # Clone input file to the local temporary file
        Copy-Item -Path $Path -Destination $localInput -Force

        $arrSimple = @()
        if (-not [string]::IsNullOrEmpty($PathRepairSimple)) {
            # Load all the simple delete instructions
            $arrSimple = Get-Content -Path $PathRepairSimple -Raw | ConvertFrom-Json
        }

        $arrReplace = @()
        if (-not [string]::IsNullOrEmpty($PathRepairReplace)) {
            # Load all the replace instructions
            $arrReplace = Get-Content -Path $PathRepairReplace -Raw | ConvertFrom-Json
        }

        Write-PSFMessage -Level Verbose -Message "Starting the Remove and Replace section of the repair." -Target @($arrSimple, $arrReplace)

        Repair-BacpacModelSimpleAndReplace -Path $localInput -OutputPath $forOutput -RemoveInstructions $arrSimple -ReplaceInstructions $arrReplace

        $arrQualifier = @()
        if (-not [string]::IsNullOrEmpty($PathRepairQualifier)) {
            # Load all the qualification delete instructions
            $arrQualifier = Get-Content -Path $PathRepairQualifier -Raw | ConvertFrom-Json
        }

        # Path to help us keep track of the file and what changes have been made - troubleshooting is easier with this one
        $localInput = Join-Path -Path $directoryObj.FullName -ChildPath "raw.qualifier.input.xml"

        # Clone input file to the local temporary file
        Copy-Item -Path $forOutput -Destination $localInput -Force

        if (-not $KeepFiles) {
            Get-ChildItem -Path "$($directoryObj.FullName)\*.simple&replace.*.xml" | Remove-Item -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
        }

        Write-PSFMessage -Level Verbose -Message "Starting the Qualifier section of the repair." -Target @($arrSimple, $arrReplace)

        for ($i = 0; $i -lt $arrQualifier.Count; $i++) {
            $forInput = Join-Path -Path $directoryObj.FullName -ChildPath "$i.qualifier.input.xml"
            $forOutput = Join-Path -Path $directoryObj.FullName -ChildPath "$i.qualifier.output.xml"

            Copy-Item -Path $localInput -Destination $forInput -Force
            Repair-BacpacModelQualifier -Path $forInput -OutputPath $forOutput -Search $arrQualifier[$i].Search -Qualifier $arrQualifier[$i].Qualifier -End $arrQualifier[$i].End

            $localInput = $forOutput
        }

        if ($arrQualifier.Count -lt 1) {
            $forOutput = $localInput
        }

        Copy-Item -Path $forOutput -Destination $OutputPath -Force

        if (-not $KeepFiles) {
            Get-ChildItem -Path "$($directoryObj.FullName)\*.qualifier.*.xml" | Remove-Item -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
        }

        [PSCustomObject]@{
            File     = $OutputPath
            Filename = $(Split-Path -Path $OutputPath -Leaf)
        }

        Invoke-TimeSignal -End
    }
}