internal/classes/DBOps.class.ps1

using namespace System.IO
using namespace System.IO.Compression

######################
# Root class DBOps #
######################

class DBOps {
    # globally setting all the properties to be exported by default
    hidden [array]$PropertiesToExport = @('*')

    # using PSF to properly throw and write messages
    hidden [void] ThrowException ([string]$Message, [string]$Category) {
        $callStack = (Get-PSCallStack)[1]
        $this.ThrowException($this, $Message, $Category, $null, $callStack)
    }
    hidden [void] ThrowException ([string]$Message, [System.Management.Automation.ErrorRecord]$ErrorRecord) {
        $callStack = (Get-PSCallStack)[1]
        $this.ThrowException($this, $Message, $null, $ErrorRecord, $callStack)
    }
    hidden [void] ThrowException ([object]$Target, [string]$Message, [string]$Category) {
        $callStack = (Get-PSCallStack)[1]
        $this.ThrowException($this, $Message, $Category, $null, $callStack)
    }
    hidden [void] ThrowException ([object]$Target, [string]$Message, [string]$Category, [System.Management.Automation.ErrorRecord]$ErrorRecord) {
        $callStack = (Get-PSCallStack)[1]
        $this.ThrowException($this, $Message, $Category, $ErrorRecord, $callStack)
    }
    hidden [void] ThrowException ([object]$Target, [string]$Message, [string]$Category, [System.Management.Automation.ErrorRecord]$ErrorRecord, [System.Management.Automation.CallStackFrame]$CallStack) {
        $splatParam = @{
            Tag             = 'DBOps', 'class', $this.GetType().Name
            FunctionName    = $this.GetType().Name
            ModuleName      = 'dbops'
            File            = $CallStack.Position.File
            Line            = $CallStack.Position.StartLineNumber
            Message         = $Message
            Target          = $Target
            ErrorRecord     = $ErrorRecord
            EnableException = $true
        }
        if ($Category) { $splatParam.Category = $Category }
        Stop-PSFFunction @splatParam
    }

    hidden [void] WriteVerbose ([string]$Message, [object]$Target) {
        $callStack = (Get-PSCallStack)[1]
        $splatParam = @{
            Tag          = 'DBOps', 'class', $this.GetType().Name
            FunctionName = $this.GetType().Name
            ModuleName   = 'dbops'
            File         = $callStack.Position.File
            Line         = $callStack.Position.StartLineNumber
            Message      = $Message
            Target       = $Target
            Level        = 'Verbose'
        }
        Write-PSFMessage @splatParam
    }

    hidden [void] WriteDebug ([string]$Message, [object]$Target) {
        $callStack = (Get-PSCallStack)[1]
        $splatParam = @{
            Tag          = 'DBOps', 'class', $this.GetType().Name
            FunctionName = $this.GetType().Name
            ModuleName   = 'dbops'
            File         = $callStack.Position.File
            Line         = $callStack.Position.StartLineNumber
            Message      = $Message
            Target       = $Target
            Level        = 'Debug'
        }
        Write-PSFMessage @splatParam
    }

    # hidden [DBOpsFile] NewFile ([string]$Name, [string]$PackagePath, [string]$CollectionName) {
    # return $this.NewFile($Name, $PackagePath, $CollectionName, [DBOpsFile])
    # }
    # hidden [DBOpsFile] NewFile ([string]$Name, [string]$PackagePath, [string]$CollectionName, [type]$Type) {
    # $f = $Type::new($Name, $PackagePath)
    # $this.AddFile($f, $CollectionName)
    # return $this.GetFile($PackagePath, $CollectionName)
    # }

    # managing files inside the collections
    hidden [void] AddFile ([DBOpsFile[]]$DBOpsFile, [string]$CollectionName) {
        $this.AddFile($DBOpsFile, $CollectionName, $false)
    }
    hidden [void] AddFile ([DBOpsFile[]]$DBOpsFile, [string]$CollectionName, $Force) {
        foreach ($file in $DBOpsFile) {
            $file.Parent = $this
            if ($CollectionName -notin $this.PsObject.Properties.Name) {
                $this.ThrowException("$CollectionName is not a valid collection name", 'InvalidArgument')
            }
            foreach ($collectionItem in $this.$CollectionName) {
                if ($collectionItem.PackagePath -eq $file.PackagePath) {
                    if ($Force) {
                        $this.RemoveFile($collectionItem, $CollectionName)
                    }
                    else {
                        $this.ThrowException("File $($file.PackagePath) already exists in $this.$CollectionName.", 'InvalidArgument')
                    }
                }
            }
            if (($this.PsObject.Properties | Where-Object Name -eq $CollectionName).TypeNameOfValue -like '*`[`]') {
                $this.$CollectionName += $file
            }
            elseif (($this.PsObject.Properties | Where-Object Name -eq $CollectionName).TypeNameOfValue -like 'System.Collections.Generic.List*') {
                $this.$CollectionName.Add($file)
            }
            else {
                $this.$CollectionName = $file
            }
        }
    }
    hidden [DBOpsFile]GetFile ([string]$PackagePath, [string]$CollectionName) {
        if (!$CollectionName) {
            $this.ThrowException("No collection name provided", 'InvalidArgument')
        }
        if (!$PackagePath) {
            $this.ThrowException('No path provided', 'InvalidArgument')
        }
        return $this.$CollectionName | Where-Object { $_.PackagePath -eq $PackagePath }
    }
    hidden [void] RemoveFile ([string[]]$PackagePath, [string]$CollectionName) {
        if ($this.$CollectionName) {
            foreach ($path in $PackagePath) {
                $file = $this.GetFile($path, $CollectionName)
                if ($file) {
                    $this.RemoveFile($file, $CollectionName)
                }
                else {
                    $this.ThrowException("File $path not found", 'InvalidArgument')
                }
            }
        }
        else {
            $this.ThrowException("Collection $CollectionName not found or empty", 'InvalidArgument')
        }
    }
    hidden [void] RemoveFile ([DBOpsFile[]]$DBOpsFile, [string]$CollectionName) {
        if ($this.$CollectionName) {
            foreach ($file in $DBOpsFile) {
                if (($this.PsObject.Properties | Where-Object Name -eq $CollectionName).TypeNameOfValue -like 'System.Collections.Generic.List*') {
                    $null = $this.$CollectionName.Remove($file)
                }
                else {
                    $this.$CollectionName = $this.$CollectionName | Where-Object { $_.PackagePath -ne $file.PackagePath }
                }

            }
        }
    }
    hidden [void] UpdateFile ([DBOpsFile[]]$DBOpsFile, [string]$CollectionName) {
        foreach ($file in $DBOpsFile) {
            $this.RemoveFile($file.PackagePath, $CollectionName)
            $this.AddFile($file, $CollectionName)
        }
    }
}

############################
# DBOpsPackageBase class #
############################

class DBOpsPackageBase : DBOps {
    #Public properties
    [System.Collections.Generic.List[DBOpsBuild]]$Builds
    [string]$ScriptDirectory
    [DBOpsFile]$DeployFile
    [System.Collections.Generic.List[DBOpsBuild]]$PostScripts
    [System.Collections.Generic.List[DBOpsBuild]]$PreScripts
    [DBOpsFile]$ConfigurationFile
    [DBOpsConfig]$Configuration
    [string]$Version
    [System.Version]$ModuleVersion
    [bool]$Slim

    #Regular file properties
    [string]$PSPath
    [string]$PSParentPath
    [string]$PSChildName
    [string]$PSDrive
    [bool]$PSIsContainer
    [string]$Mode
    [string]$BaseName
    [string]$Name
    [int]$Length
    [string]$DirectoryName
    [System.IO.DirectoryInfo]$Directory
    [bool]$IsReadOnly
    [bool]$Exists
    [string]$FullName
    [string]$Extension
    [datetime]$CreationTime
    [datetime]$CreationTimeUtc
    [datetime]$LastAccessTime
    [datetime]$LastAccessTimeUtc
    [datetime]$LastWriteTime
    [datetime]$LastWriteTimeUtc
    [System.IO.FileAttributes]$Attributes

    #hidden properties
    hidden [string]$FileName
    hidden [string]$PackagePath
    hidden [array]$PropertiesToExport = @('ScriptDirectory', 'DeployFile', 'PreScripts', 'PostScripts', 'ConfigurationFile', 'Builds', 'Slim')

    DBOpsPackageBase () {
        $this.Builds = [System.Collections.Generic.List[DBOpsBuild]]::new()
        $this.PreScripts = [System.Collections.Generic.List[DBOpsBuild]]::new()
        $this.PostScripts = [System.Collections.Generic.List[DBOpsBuild]]::new()
    }

    #Methods
    [void] Init () {
        $this.ScriptDirectory = 'content'
        $this.Configuration = [DBOpsConfig]::new()
        $this.Configuration.Parent = $this
        $this.PackagePath = ""
        $this.Slim = $false
    }
    [void] Init ([object]$jsonObject) {
        $this.Init()
        if ($jsonObject) {
            $this.ScriptDirectory = $jsonObject.ScriptDirectory
            if ($jsonObject.Slim) { $this.Slim = $jsonObject.Slim }
        }
    }
    [void] RefreshFileProperties() {
        if ($this.FileName) {
            $FileObject = Get-Item -LiteralPath $this.FileName -ErrorAction Stop
            $this.PSPath = $FileObject.PSPath.ToString()
            $this.PSParentPath = $FileObject.PSParentPath.ToString()
            $this.PSChildName = $FileObject.PSChildName.ToString()
            $this.PSDrive = $FileObject.PSDrive.ToString()
            $this.PSIsContainer = $FileObject.PSIsContainer
            $this.Mode = $FileObject.Mode
            $this.BaseName = $FileObject.BaseName
            $this.Name = $FileObject.Name
            $this.Length = $FileObject.Length
            $this.DirectoryName = $FileObject.DirectoryName
            if ($FileObject.Directory) {
                $this.Directory = $FileObject.Directory.ToString()
            }
            $this.IsReadOnly = $FileObject.IsReadOnly
            $this.Exists = $FileObject.Exists
            $this.FullName = $FileObject.FullName
            $this.Extension = $FileObject.Extension
            $this.CreationTime = $FileObject.CreationTime
            $this.CreationTimeUtc = $FileObject.CreationTimeUtc
            $this.LastAccessTime = $FileObject.LastAccessTime
            $this.LastAccessTimeUtc = $FileObject.LastAccessTimeUtc
            $this.LastWriteTime = $FileObject.LastWriteTime
            $this.LastWriteTimeUtc = $FileObject.LastWriteTimeUtc
            $this.Attributes = $FileObject.Attributes

            # Also refresh DBOps module version from the archive
            $this.RefreshModuleVersion()
        }
    }
    [DBOpsBuild[]] GetBuilds () {
        return $this.Builds
    }
    [DBOpsBuild] NewBuild ([string]$build) {
        return $this.NewBuild($build, 'Builds')
    }
    [DBOpsBuild] NewBuild ([string]$build, [string]$type) {
        if (!$build) {
            $this.ThrowException('Build name is not specified.', 'InvalidArgument')
            return $null
        }
        if ($this.$type | Where-Object { $_.build -eq $build }) {
            $this.ThrowException("Build $build already exists in $type.", 'InvalidArgument')
            return $null
        }
        else {
            $newBuild = [DBOpsBuild]::new($build)
            $newBuild.Parent = $this
            $this.$type.Add($newBuild)
            if ($type -eq 'Builds') {
                $this.Version = $newBuild.Build
            }
            return $newBuild
        }
    }

    [array] EnumBuilds () {
        return $this.builds.build
    }
    [string] GetVersion () {
        return $this.Version
    }

    [System.Collections.Generic.List[DBOpsBuild]] GetBuild ([string[]]$build) {
        if ($currentBuild = $this.builds | Where-Object { $_.build -in $build }) {
            return $currentBuild
        }
        else {
            return [System.Collections.Generic.List[DBOpsBuild]]::new()
        }
    }
    [void] AddBuild ([DBOpsBuild]$build) {
        $this.AddBuildToCollection($build, 'builds')
    }
    [void] AddBuildToCollection ([DBOpsBuild]$build, $collection) {
        if ($this.$collection | Where-Object { $_.build -eq $build.build }) {
            $this.ThrowException("Build $build already exists.", 'InvalidArgument')
        }
        else {
            $build.Parent = $this
            $this.$collection.Add($build)
            if ($collection -eq 'Builds') {
                $this.Version = $build.Build
            }
        }
    }
    [void] SetBuildCollection ([System.Collections.Generic.List[DBOpsBuild]]$build, $collection) {
        $buildCollection = [System.Collections.Generic.List[DBOpsBuild]]::new()
        foreach ($b in $build) {
            $b.Parent = $this
            $buildCollection.Add($b)
            if ($collection -eq 'Builds') {
                $this.Version = $build.Build
            }
        }
        $this.$collection = $buildCollection
    }

    [void] RemoveBuild ([System.Collections.Generic.List[DBOpsBuild]]$build) {
        foreach ($buildItem in $build) {
            $this.builds.Remove($buildItem)
        }
        if ($this.Builds.Count -gt 0) {
            $this.Version = $this.Builds[-1].Build
        }
        else {
            $this.Version = [NullString]::Value
        }
    }
    [void] RemoveBuild ([string[]]$build) {
        $this.RemoveBuild($this.GetBuild($build))
    }
    [bool] ScriptExists([string]$fileName) {
        foreach ($build in $this.builds) {
            if ($build.ScriptExists($fileName)) {
                return $true
            }
        }
        return $false
    }
    [bool] ScriptExists([DBOpsFile]$file) {
        foreach ($build in $this.builds) {
            if ($build.ScriptExists($file)) {
                return $true
            }
        }
        return $false
    }
    [bool] ScriptModified([string]$fileName, [string]$packagePath) {
        if (!(Test-Path $fileName)) {
            $this.ThrowException("Path not found: $fileName", 'InvalidArgument')
        }
        $hash = [DBOpsHelper]::ToHexString([Security.Cryptography.HashAlgorithm]::Create("MD5").ComputeHash([DBOpsHelper]::GetBinaryFile($fileName)))
        foreach ($build in $this.Builds) {
            if ($build.PackagePathExists($packagePath)) {
                if (-not $build.HashExists($hash, $packagePath)) {
                    return $true
                }
                break
            }
        }
        return $false
    }
    [bool] ScriptModified([DBOpsFile]$file) {
        foreach ($build in $this.Builds) {
            if ($build.PackagePathExists($file.PackagePath)) {
                if (-not $build.HashExists($file.Hash, $file.PackagePath)) {
                    return $true
                }
                break
            }
        }
        return $false
    }
    [bool] PackagePathExists([string]$PackagePath) {
        foreach ($build in $this.builds) {
            if ($build.PackagePathExists($PackagePath)) {
                return $true
            }
        }
        return $false
    }
    [string] ExportToJson() {
        $exportObject = @{ } | Select-Object -Property $this.PropertiesToExport
        foreach ($type in $exportObject.psobject.Properties.name) {
            $property = $this.PsObject.Properties | Where-Object Name -eq $type
            if ($this.$type -is [DBOps]) {
                $exportObject.$type = $this.$type.ExportToJson() | ConvertFrom-Json
            }
            elseif ($property.TypeNameOfValue -like '*`[`]' -or $property.TypeNameOfValue -like 'System.Collections.Generic.List*') {
                $collection = @()
                foreach ($collectionItem in $this.$type) {
                    if ($collectionItem -is [DBOps]) {
                        $collection += $collectionItem.ExportToJson() | ConvertFrom-Json
                    }
                    else {
                        $collection += $collectionItem
                    }
                }
                $exportObject.$type = $collection
            }
            else {
                $exportObject.$type = $this.$type
            }

        }
        return $exportObject | ConvertTo-Json -Depth 4
    }
    hidden [void] SavePackageFile([ZipArchive]$zipFile) {
        $pkgFileContent = [Text.Encoding]::ASCII.GetBytes($this.ExportToJson())
        [DBOpsHelper]::WriteZipFile($zipFile, ([DBOpsConfig]::GetPackageFileName()), $pkgFileContent)
    }
    [void] Alter() {
        $this.SaveToFile($this.FileName, $true)
    }
    [void] Save() {
        $this.SaveToFile($this.FileName, $true)
    }
    [void] SaveToFile([string]$fileName) {
        $this.SaveToFile($fileName, $false)
    }
    [void] SaveToFile([string]$fileName, [bool]$force) {
        $parentFolder = Split-Path $fileName -Parent
        if (!$parentFolder) {
            $parentFolder = (Get-Location).Path
        }
        else {
            $parentFolder = (Get-Item -LiteralPath $parentFolder -ErrorAction Stop).FullName
        }
        $currentFileName = Join-Path $parentFolder (Split-Path $filename -Leaf)
        #Open new file stream
        $writeMode = switch ($force) {
            $true { [System.IO.FileMode]::Create }
            default { [System.IO.FileMode]::CreateNew }
        }
        $stream = $null
        try {
            $stream = [FileStream]::new($currentFileName, $writeMode)
        }
        catch {
            $this.ThrowException("Failed to open filestream to $currentFileName with mode $writeMode", $_)
        }
        try {
            #Create zip file
            $zip = [ZipArchive]::new($stream, [ZipArchiveMode]::Create)
            try {
                #Change package file name in the object if it wasn't set before
                if (!$this.FileName) {
                    $this.FileName = $currentFileName
                }
                #Write package file
                $this.SavePackageFile($zip)
                #Write files
                foreach ($type in @('DeployFile', 'PreScripts', 'PostScripts', 'Builds')) {
                    foreach ($collectionItem in $this.$type) {
                        $collectionItem.Save($zip)
                    }
                }

                #Write configs
                $this.Configuration.Save($zip)

                #Write module
                $this.SaveModuleToFile($zip)
            }
            catch { throw $_ }
            finally { $zip.Dispose() }
        }
        catch {
            $this.ThrowException("Failed to complete the deflate operation against archive $currentFileName", $_)
        }
        finally { $stream.Dispose() }

        # Setting regular file properties
        $this.RefreshFileProperties()
    }

    hidden [void] SaveModuleToFile([ZipArchive]$zipArchive) {
        if (-not $this.Slim) {
            foreach ($file in (Get-DBOModuleFileList)) {
                [DBOpsHelper]::WriteZipFile($zipArchive, (Join-PSFPath -Normalize "Modules\dbops" $file.Path), [DBOpsHelper]::GetBinaryFile($file.FullName))
            }
            # import other modules
            $modules = Get-Module dbops | Select-Object -ExpandProperty RequiredModules
            foreach ($module in $modules) {
                Push-Location $module.ModuleBase
                foreach ($file in (Get-ChildItem -Recurse -File)) {
                    $relativePath = (Resolve-Path -Relative -LiteralPath $file.FullName) -replace '^\.', ''
                    [DBOpsHelper]::WriteZipFile($zipArchive, (Join-PSFPath -Normalize "Modules\$($module.Name)" $relativePath), [DBOpsHelper]::GetBinaryFile($file.FullName))
                }
                Pop-Location
            }
        }
    }
    #Returns root folder
    [string] GetPackagePath() {
        return ""
    }
    #Returns root folder
    [string] GetDeploymentPath() {
        return ""
    }
    #Returns content folder for scripts
    [string] GetContentPath() {
        return $this.ScriptDirectory
    }

    #Refresh module version from the module file inside the package
    [void] RefreshModuleVersion() {
        if ($this.FileName) {
            $manifestPackagePath = Join-PSFPath -Normalize 'Modules\dbops\dbops.psd1'
            $contents = ([DBOpsHelper]::GetArchiveItem($this.FileName, $manifestPackagePath)).ByteArray
            $scriptBlock = [scriptblock]::Create([DBOpsHelper]::DecodeBinaryText($contents))
            $moduleFile = Invoke-Command -ScriptBlock $scriptBlock
            $this.ModuleVersion = [System.Version]$moduleFile.ModuleVersion
        }
    }

    #Standard ToString() method
    [string] ToString () {
        if ($this.FullName) {
            return $this.FullName
        }
        else {
            return "[DBOpsPackage]"
        }
    }

    #Sets package configuration
    [void] SetConfiguration([DBOpsConfig]$config) {
        $this.Configuration = $config
        $config.Parent = $this
    }

    #Read json and adjust paths appropriately to the environment (Win/Linux)
    [object] ReadMetadata([string]$jsonString) {
        $jsonObject = ConvertFrom-Json $jsonString -ErrorAction Stop
        foreach ($build in $jsonObject.Builds) {
            foreach ($script in $build.Scripts) {
                $script.PackagePath = Join-PSFPath -Normalize $script.PackagePath
            }
        }
        return $jsonObject
    }

    #Sets the package prescripts
    [void] SetPreScripts([DBOpsFile[]]$scripts) {
        $preBuild = [DBOpsBuild]::new('.dbops.prescripts')
        $preBuild.AddScript($scripts)
        $this.SetBuildCollection($preBuild, 'PreScripts')
    }

    #Sets the package postscripts
    [void] SetPostScripts([DBOpsFile[]]$scripts) {
        $postBuild = [DBOpsBuild]::new('.dbops.postscripts')
        $postBuild.AddScript($scripts)
        $this.SetBuildCollection($postBuild, 'PostScripts')
    }

    #Gets the package prescripts
    [System.Collections.Generic.List[DBOpsFile]] GetPreScripts() {
        return $this.PreScripts.Scripts
    }

    #Gets the package postscripts
    [System.Collections.Generic.List[DBOpsFile]] GetPostScripts() {
        return $this.PostScripts.Scripts
    }
}
########################
# DBOpsPackage class #
########################

# Supports creating a package object from a zip file, working around a shared default constructor in a base class

class DBOpsPackage : DBOpsPackageBase {
    #Constructors
    DBOpsPackage () {

        $this.Init()
        # Processing deploy file
        $file = [DBOpsConfig]::GetDeployFile()
        # Adding root deploy file
        $deployFileObject = Get-Item $file.FullName -ErrorAction Stop
        $this.AddFile([DBOpsFile]::new($deployFileObject, $file.Name), 'DeployFile')
        # Adding configuration file default contents
        $configFile = [DBOpsFile]::new([DBOpsConfig]::GetConfigurationFileName())
        $configContent = [Text.Encoding]::ASCII.GetBytes($this.Configuration.ExportToJson())
        $configFile.SetContent($configContent)
        $this.AddFile($configFile, 'ConfigurationFile')
    }

    DBOpsPackage ([string]$fileName) {

        if (!(Test-Path $fileName -PathType Leaf)) {
            throw "File $fileName not found. Aborting."
        }
        $this.FileName = $fileName
        # Setting regular file properties
        $this.RefreshFileProperties()
        # Reading zip file contents into memory
        $zip = [Zipfile]::OpenRead($fileName)
        try {
            # Processing package file
            $pkgFile = $zip.Entries | Where-Object FullName -eq ([DBOpsConfig]::GetPackageFileName())
            if ($pkgFile) {
                $pkgFileBin = [DBOpsHelper]::ReadDeflateStream($pkgFile.Open()).ToArray()
                $jsonObject = $this.ReadMetadata([DBOpsHelper]::DecodeBinaryText($pkgFileBin))
                $this.Init($jsonObject)
                # Processing builds
                foreach ($buildType in 'Builds', 'PreScripts', 'PostScripts') {
                    foreach ($build in $jsonObject.$buildType) {
                        $newBuild = $this.NewBuild($build.build, $buildType)
                        foreach ($script in $build.Scripts) {
                            $filePackagePath = Join-Path $newBuild.GetPackagePath() $script.packagePath
                            $scriptFile = $zip.Entries | Where-Object { (Join-PSFPath -Normalize $_.FullName) -eq $filePackagePath }
                            if (!$scriptFile) {
                                $this.ThrowException("File not found inside the package: $filePackagePath", 'InvalidArgument')
                            }
                            $newScript = [DBOpsFile]::new($scriptFile, $script.PackagePath, $script.Hash)
                            $newBuild.AddScript($newScript, $true)
                        }
                    }
                }
                # Processing root files
                foreach ($file in @('DeployFile', 'ConfigurationFile')) {
                    foreach ($jsonFileObject in $jsonObject.$file) {
                        $zipFileEntry = $zip.Entries | Where-Object { (Join-PSFPath -Normalize $_.FullName) -eq $jsonFileObject.packagePath }
                        if ($zipFileEntry) {
                            $newFile = [DBOpsFile]::new($zipFileEntry, $jsonFileObject.PackagePath)
                            $this.AddFile($newFile, $file)
                        }
                        else {
                            $this.ThrowException("File $($jsonFileObject.packagePath) not found in the package", 'InvalidData')
                        }
                    }
                }
            }
            else {
                $this.ThrowException("Incorrect package format: $fileName", 'InvalidArgument')
            }

            # Processing configuration file
            if ($this.ConfigurationFile) {
                $this.Configuration = [DBOpsConfig]::new($this.ConfigurationFile.GetContent())
                $this.Configuration.Parent = $this
            }
        }
        catch {
            $this.ThrowException("Failed to complete the deflate operation against archive $fileName", $_)
        }
        finally {
            # Dispose of the reader
            $zip.Dispose()
        }
    }

}

############################
# DBOpsPackageFile class #
############################

# Supports creating a package object from an extracted zip - basically from a json file
class DBOpsPackageFile : DBOpsPackageBase {

    #Apparently, inheriting a class will run a default constructor anyways, DBOpsPackageBase is a new class that has no constructor

    DBOpsPackageFile ([string]$fileName) {
        if (!(Test-Path $fileName -PathType Leaf)) {
            $this.ThrowException($fileName, "File $fileName not found. Aborting.", 'ObjectNotFound')
        }
        # Processing package file
        $pkgFileBin = [DBOpsHelper]::GetBinaryFile($fileName)
        if ($pkgFileBin) {
            $jsonObject = $this.ReadMetadata([DBOpsHelper]::DecodeBinaryText($pkgFileBin))
            $this.Init($jsonObject)
            #Defining package path as a parent folder of the package file
            $folderPath = Split-Path $fileName -Parent
            $this.FileName = $folderPath
            # Setting regular file properties
            $this.RefreshFileProperties()

            $this.PackagePath = $folderPath
            # Processing builds
            foreach ($buildType in 'Builds', 'PreScripts', 'PostScripts') {
                foreach ($build in $jsonObject.$buildType) {
                    $newBuild = $this.NewBuild($build.build, $buildType)
                    foreach ($script in $build.Scripts) {
                        $contentPath = Join-Path $folderPath $newBuild.GetPackagePath()
                        $filePackagePath = Join-Path $contentPath $script.packagePath
                        if (!(Test-Path $filePackagePath)) {
                            $this.ThrowException("File not found inside the package: $filePackagePath", 'InvalidArgument')
                        }
                        $fileObject = Get-Item -LiteralPath $filePackagePath -ErrorAction Stop
                        $newScript = [DBOpsFile]::new($fileObject, $script.PackagePath, $script.Hash)
                        $newBuild.AddScript($newScript, $true)
                    }
                }
            }
            # Processing root files
            foreach ($fileType in @('DeployFile', 'ConfigurationFile')) {
                $jsonFileObject = $jsonObject.$fileType
                if ($jsonFileObject) {
                    $filePackagePath = Join-Path $folderPath $jsonFileObject.packagePath
                    if (!(Test-Path $filePackagePath)) {
                        $this.ThrowException("File not found inside the package: $filePackagePath", 'InvalidArgument')
                    }
                    $fileObject = Get-Item -LiteralPath $filePackagePath -ErrorAction Stop
                    $newFile = [DBOpsFile]::new($fileObject, $jsonFileObject.PackagePath)
                    $this.AddFile($newFile, $fileType)
                }
            }
        }
        else {
            $this.ThrowException("Incorrect package format: $fileName", 'InvalidArgument')
        }

        # Processing configuration file
        if ($this.ConfigurationFile) {
            $this.Configuration = [DBOpsConfig]::new($this.ConfigurationFile.GetContent())
            $this.Configuration.Parent = $this
        }

    }

    #overloads to prefent unpacked packages from being saved
    [void] Alter() {
        $this.ThrowException("Unpacked package cannot be saved without compressing it first. Use SaveToFile('myfile') instead.", 'InvalidArgument')
    }
    [void] Save() {
        $this.Alter()
    }

    #Overload to read module file from the folder
    [void] RefreshModuleVersion() {
        if ($this.FileName) {
            $manifestPackagePath = Join-PSFPath -Normalize $this.FileName 'Modules\dbops\dbops.psd1'
            $contents = ([DBOpsHelper]::GetBinaryFile($manifestPackagePath))
            $scriptBlock = [scriptblock]::Create([DBOpsHelper]::DecodeBinaryText($contents))
            $moduleFile = Invoke-Command -ScriptBlock $scriptBlock
            $this.ModuleVersion = [System.Version]$moduleFile.ModuleVersion
        }
    }

}

######################
# DBOpsBuild class #
######################

class DBOpsBuild : DBOps {
    #Public properties
    [string]$Build
    [System.Collections.Generic.List[DBOpsFile]]$Scripts
    [string]$CreatedDate

    hidden [DBOpsPackageBase]$Parent
    hidden [string]$PackagePath
    hidden [array]$PropertiesToExport = @('Build', 'CreatedDate', 'PackagePath')

    #Constructors
    DBOpsBuild ([string]$build) {
        if (!$build) {
            $this.ThrowException('Build name cannot be empty', 'InvalidArgument');
        }
        $this.Build = $build
        $this.PackagePath = $build
        $this.CreatedDate = (Get-Date).Datetime
        $this.Scripts = [System.Collections.Generic.List[DBOpsFile]]::new()
    }

    hidden DBOpsBuild ([psobject]$object) {
        if (!$object.Build) {
            $this.ThrowException('Build name cannot be empty', 'InvalidArgument');
        }
        $this.Build = $object.Build
        $this.PackagePath = $object.PackagePath
        $this.CreatedDate = $object.CreatedDate
    }

    #Methods
    # Adds script to the current build
    [void] AddScript ([DBOpsFile[]]$script) {
        $this.AddScript($script, $false)
    }
    [void] AddScript ([DBOpsFile[]]$script, [bool]$Force) {
        foreach ($s in $script) {
            if ($Force -and $this.PackagePathExists($s.PackagePath)) {
                $this.RemoveScript($s.PackagePath)
            }
            $this.AddFile($s, 'Scripts')
        }
    }
    # returns script(s) from the build
    [DBOpsFile[]] GetScript ([string[]]$packagePath) {
        [DBOpsFile[]]$scriptList = @()
        foreach ($p in $packagePath) {
            $scriptList += $this.GetFile($p, 'Scripts')
        }
        return $scriptList
    }
    # removes script(s) from the build
    [void] RemoveScript ([string[]]$packagePath) {
        $this.RemoveFile($packagePath, 'Scripts')
    }
    [string] ToString() {
        return "[$($this.build)]"
    }
    #Searches for a certain hash value within the build
    hidden [bool] HashExists([string]$hash) {
        foreach ($script in $this.Scripts) {
            if ($hash -eq $script.Hash) {
                return $true
            }
        }
        return $false
    }
    #Searches for a certain hash value within the build for a specific source file
    hidden [bool] HashExists([string]$hash, [string]$packagePath) {
        if ($script = $this.GetScript($packagePath)) {
            if ($hash -eq $script.Hash) {
                return $true
            }
        }
        return $false
    }
    #Compares file hash and returns true if such has has been found within the build
    [bool] ScriptExists([string]$fileName) {
        if (!(Test-Path $fileName)) {
            $this.ThrowException("Path not found: $fileName", 'InvalidArgument')
        }
        $fileObject = Get-Item $fileName -ErrorAction Stop
        $hash = [DBOpsHelper]::ToHexString([Security.Cryptography.HashAlgorithm]::Create("MD5").ComputeHash([DBOpsHelper]::GetBinaryFile($fileObject.FullName)))
        return $this.HashExists($hash)
    }
    [bool] ScriptExists([DBOpsFile]$file) {
        if (-not $file.Protected) {
            $this.ThrowException("Provided file is not hash-protected: $($file.FullName)", 'InvalidArgument')
        }
        return $this.HashExists($file.Hash)
    }
    #Returns true if the file was modified since it last has been added to the build
    [bool] ScriptModified([DBOpsFile]$dbopsFile) {
        if (!(Test-Path $dbopsFile.FullName)) {
            $this.ThrowException("Path not found: $($dbopsFile.FullName)", 'InvalidArgument')
        }
        if (-not $dbopsFile.Protected) {
            $this.ThrowException("Provided file is not hash-protected: $($dbopsFile.FullName)", 'InvalidArgument')
        }
        if ($this.PackagePathExists($dbopsFile.PackagePath)) {
            return -not $this.HashExists($dbopsFile.Hash, $dbopsFile.PackagePath)
        }
        else {
            return $false
        }
    }
    #Verify if Package Path is already used by a different file
    [bool] PackagePathExists([string]$PackagePath) {
        foreach ($script in $this.Scripts) {
            if ($PackagePath -eq $script.PackagePath) {
                return $true
            }
        }
        return $false
    }
    #Get absolute path inside the package
    [string] GetPackagePath() {
        if ($this.Parent) {
            return Join-PSFPath $this.Parent.GetContentPath() $this.PackagePath
        }
        else {
            return $this.PackagePath
        }
    }
    #Get deployment path
    [string] GetDeploymentPath() {
        return $this.PackagePath
    }
    #Exports object to Json in the format in which it will be stored in the package file
    [string] ExportToJson() {
        $scriptCollection = @()
        foreach ($script in $this.Scripts) {
            $scriptCollection += $script.ExportToJson() | ConvertFrom-Json
        }
        $output = $this | Select-Object -Property $this.PropertiesToExport
        $output | Add-Member -MemberType NoteProperty -Name Scripts -Value $scriptCollection
        return $output | ConvertTo-Json -Depth 2
    }
    #Writes current build into the archive file
    hidden [void] Save([ZipArchive]$zipFile) {
        foreach ($script in $this.Scripts) {
            $script.Save($zipFile)
        }
    }
    #Alter build - includes module updates and scripts
    [void] Alter() {
        # check if parent exists
        if (-not $this.Parent) {
            $this.ThrowException("Parent of $this has not been defined", 'InvalidOperation')
        }
        #Open new file stream
        $writeMode = [System.IO.FileMode]::Open
        $stream = $null
        try {
            $stream = [FileStream]::new($this.Parent.FileName, $writeMode)
        }
        catch {
            $this.ThrowException("Failed to open filestream to $($this.Parent.FileName) with mode $writeMode", $_)
        }

        try {
            #Open zip file
            $zip = [ZipArchive]::new($stream, [ZipArchiveMode]::Update)
            try {
                #Write package file
                $this.Parent.SavePackageFile($zip)
                #Write builds
                $this.Save($zip)
                #Write module
                $this.Parent.SaveModuleToFile($zip)
            }
            catch { throw $_ }
            finally { $zip.Dispose() }
        }
        catch {
            $this.ThrowException("Failed to modify archive $($this.Parent.FileName)", $_)
        }
        finally { $stream.Dispose()    }

        # Refreshing regular file properties for parent object
        $this.Parent.RefreshFileProperties()
    }
}

####################
# DBOpsFile class #
####################
class DBOpsFile : DBOps {
    #Public properties
    [string]$PackagePath
    [string]$FullName
    [int]$Length
    [string]$Name
    [string]$LastWriteTime
    [byte[]]$ByteArray

    #Hidden properties
    hidden [string]$Hash
    hidden [bool]$Protected
    hidden [DBOps]$Parent
    hidden [array]$PropertiesToExport = @('PackagePath')

    #Constructors
    DBOpsFile ([string]$packagePath) {
        $this.Init($packagePath)
        $this.Protected = $false
    }
    DBOpsFile ([System.IO.FileInfo]$file, [string]$packagePath) {
        #Set properties imported from package file
        $this.Init($packagePath)
        $this.Protected = $false
        $this.InitFile($file)
    }
    DBOpsFile ([System.IO.FileInfo]$file, [string]$packagePath, [bool]$hashProtected) {
        #Set properties imported from package file
        $this.Init($packagePath)
        $this.Protected = $hashProtected
        $this.InitFile($file)
    }
    DBOpsFile ([System.IO.FileInfo]$file, [string]$packagePath, [string]$hash) {
        #Set properties imported from package file
        $this.Init($packagePath)
        $this.Protected = $true
        # read the file
        $this.InitFile($file)
        # validate the hash
        $this.ValidateHash($hash)
    }
    DBOpsFile ([ZipArchiveEntry]$zipFile, [string]$packagePath) {
        #Set properties imported from package file
        $this.Init($packagePath)
        $this.Protected = $false
        #Set properties from Zip archive
        $this.InitZipFile($zipFile)
    }
    DBOpsFile ([ZipArchiveEntry]$zipFile, [string]$packagePath, [string]$hash) {
        #Set properties imported from package file
        $this.Init($packagePath)
        $this.Protected = $true
        #Set properties from Zip archive
        $this.InitZipFile($zipFile)
        $this.ValidateHash($hash)
    }

    #Methods
    [void] Init ([string]$packagePath) {
        if (!$packagePath) {
            $this.ThrowException('Path inside the package cannot be empty', 'InvalidArgument')
        }
        $this.PackagePath = $packagePath
    }
    [void] InitFile ([System.IO.FileInfo]$file) {
        #Set properties from the file
        $this.Name = $file.Name
        $this.FullName = $file.FullName
        $this.LastWriteTime = $file.LastWriteTime
        # set contents
        $this.SetContent([DBOpsHelper]::GetBinaryFile($file.FullName))
    }
    [void] InitZipFile ([ZipArchiveEntry]$zipFile) {
        #Set properties from Zip archive
        $this.Name = $zipFile.Name
        $this.LastWriteTime = $zipFile.LastWriteTime

        #Read deflate stream and set other properties
        $stream = [DBOpsHelper]::ReadDeflateStream($zipFile.Open())
        try {
            $this.SetContent($stream.ToArray())
        }
        catch {
            $this.ThrowException("Failed to read deflate stream from $($zipFile.Name)", $_)
        }
        finally {
            $stream.Dispose()
        }
    }
    [string] ToString() {
        return "$($this.PackagePath)"
    }
    [string] GetContent() {
        return [DBOpsHelper]::DecodeBinaryText($this.ByteArray)
    }
    [string] GetPackagePath() {
        $pPath = $this.PackagePath
        # removing odd symbols
        $pPath = $pPath -replace ':', ''
        if ($this.Parent) {
            if ($parentPath = $this.Parent.GetPackagePath()) {
                $pPath = Join-Path $this.Parent.GetPackagePath() $pPath
            }
        }
        return $pPath
    }
    [string] GetDeploymentPath () {
        $dPath = $this.PackagePath
        # removing odd symbols
        $dPath = $dPath -replace ':', ''
        if ($this.Parent) {
            if ($parentPath = $this.Parent.GetDeploymentPath()) {
                $dPath = Join-Path $this.Parent.GetDeploymentPath() $dPath
            }
        }
        # always use backslashes during deployments regardless of the OS
        return $dPath.Replace('/', '\')
    }
    [string] ExportToJson() {
        $expObject = @{ } | Select-Object -Property $this.PropertiesToExport
        foreach ($prop in $this.PropertiesToExport) {
            $expObject.$prop = $this.$prop
        }
        # replace symbols in PackagePath
        $expObject.PackagePath = $this.PackagePath -replace ':', ''
        return $expObject | ConvertTo-Json -Depth 1
    }
    #Writes current script into the archive file
    [void] Save([ZipArchive]$zipFile) {
        [DBOpsHelper]::WriteZipFile($zipFile, $this.GetPackagePath(), $this.ByteArray)
    }
    #Updates package content
    [void] SetContent([byte[]]$Array) {
        $this.ByteArray = $Array
        $this.Length = $Array.Length
        if ($this.Protected) {
            # calculate the hash
            $this.RebuildHash()
            # mark Hash as exportable property
            if ('Hash' -notin $this.PropertiesToExport) {
                $this.PropertiesToExport += 'Hash'
            }
        }
    }
    #Recalculates Hash
    [void] RebuildHash() {
        if ($this.Length -gt 0) {
            $this.Hash = [DBOpsHelper]::ToHexString([Security.Cryptography.HashAlgorithm]::Create("MD5").ComputeHash($this.ByteArray))
        }
    }
    #Verify that hash is valid
    [void] ValidateHash([string]$hash) {
        if ($this.hash -ne $hash) {
            $this.ThrowException("File cannot be loaded, hash mismatch: $($this.Name)", 'InvalidArgument')
        }
    }
    #Initiates package update saving the current file in the package
    [void] Alter() {
        #Open new file stream
        $writeMode = [System.IO.FileMode]::Open
        if ($this.Parent -is [DBOpsBuild]) {
            $pkgObj = $this.Parent.Parent
        }
        elseif ($this.Parent -is [DBOpsPackage]) {
            $pkgObj = $this.Parent
        }
        else {
            $pkgObj = $null
        }
        $stream = $null
        try {
            $stream = [FileStream]::new($pkgObj.FileName, $writeMode, [System.IO.FileAccess]::ReadWrite)
        }
        catch {
            $this.ThrowException("Failed to open filestream to $($pkgObj.FileName) with mode ReadWrite", $_)
        }
        try {
            #Open zip file
            $zip = [ZipArchive]::new($stream, [ZipArchiveMode]::Update)
            try {
                #Write file
                $this.Save($zip)
                #Update package file
                $pkgObj.SavePackageFile($zip)
            }
            catch { throw $_ }
            finally { $zip.Dispose() }
        }
        catch {
            $this.ThrowException("Failed to modify archive $($pkgObj.FileName)", $_)
        }
        finally { $stream.Dispose()    }

        # Refreshing regular file properties for parent object
        if ($pkgObj) {
            $pkgObj.RefreshFileProperties()
        }
    }
}

#######################
# DBOpsConfig class #
#######################

class DBOpsConfig : DBOps {
    #Properties
    [string]$ApplicationName
    [string]$SqlInstance
    [string]$Database
    [string]$DeploymentMethod
    [System.Nullable[int]]$ConnectionTimeout
    [System.Nullable[int]]$ExecutionTimeout
    [System.Nullable[bool]]$Encrypt
    [pscredential]$Credential
    [string]$Username
    [SecureString]$Password
    [string]$SchemaVersionTable
    [System.Nullable[bool]]$Silent
    [psobject]$Variables
    [string]$Schema
    [System.Nullable[bool]]$CreateDatabase
    [string]$ConnectionString
    [psobject]$ConnectionAttribute

    hidden [DBOpsPackageBase]$Parent

    #Constructors
    DBOpsConfig () {
        $this.Init()
    }
    DBOpsConfig ([string]$jsonString) {
        if (!$jsonString) {
            $this.ThrowException("Input string has not been defined", 'InvalidArgument')
        }
        $this.Init()

        $jsonConfig = $jsonString | ConvertFrom-Json -ErrorAction Stop

        foreach ($property in $jsonConfig.psobject.properties.Name) {
            if ($property -in [DBOpsConfig]::EnumProperties()) {
                $this.SetValue($property, $jsonConfig.$property)
            }
            else {
                $this.ThrowException("$property is not a valid configuration item", 'InvalidArgument')
            }
        }
    }
    #Hidden methods
    hidden [void] Init () {
        #Reading default values from PSF
        foreach ($prop in [DBOpsConfig]::EnumProperties()) {
            $configValue = Get-PSFConfigValue -FullName dbops.$prop
            $this.SetValue($prop, $configValue)
        }
    }

    #Methods
    [hashtable] AsHashtable () {
        $ht = @{ }
        foreach ($property in $this.psobject.Properties.Name) {
            $ht += @{ $property = $this.$property }
        }
        return $ht
    }

    [void] SetValue ([string]$Property, [object]$Value) {
        if ([DBOpsConfig]::EnumProperties() -notcontains $Property) {
            $this.ThrowException("$property is not a valid configuration item", 'InvalidArgument')
        }
        #set proper NullString for String properties
        if ($null -eq $Value -and $Property -in ($this.PsObject.Properties | Where-Object TypeNameOfValue -like 'System.String*').Name) {
            $this.$Property = [NullString]::Value
        }
        elseif ($null -ne $Value -and $Property -eq 'Password') {
            if ($Value -is [SecureString]) {
                $this.$Property = $Value
            }
            else {
                $this.$Property = ConvertFrom-EncryptedString -String $Value
            }
        }
        elseif ($null -ne $Value -and $Property -eq 'Credential') {
            if ($Value -is [pscredential]) {
                $this.$Property = $Value
            }
            else {
                $this.$Property = [pscredential]::new($Value.UserName, (ConvertFrom-EncryptedString -String $Value.Password))
            }
        }
        else {
            $this.$Property = $Value
        }
    }
    # Returns a JSON string representin the object
    [string] ExportToJson() {
        $outObject = @{ }
        foreach ($prop in [DBOpsConfig]::EnumProperties()) {
            if ($this.$prop -is [securestring]) {
                $outObject += @{ $prop = $this.$prop | ConvertTo-EncryptedString }
            }
            elseif ($this.$prop -is [pscredential]) {
                $outObject += @{
                    $prop = @{
                        UserName = $this.$prop.UserName
                        Password = $this.$prop.Password | ConvertTo-EncryptedString
                    }
                }
            }
            else {
                $outObject += @{ $prop = $this.$prop }
            }
        }
        return $outObject | ConvertTo-Json -Depth 3
    }
    # Save package to an opened zip file
    [void] Save([ZipArchive]$zipFile) {
        if (-not $this.Parent) {
            $this.ThrowException("Parent of $this has not been defined", 'InvalidOperation')
        }
        $fileContent = [Text.Encoding]::ASCII.GetBytes($this.ExportToJson())
        if ($this.Parent.ConfigurationFile) {
            $filePath = $this.Parent.ConfigurationFile.PackagePath
            $this.Parent.ConfigurationFile.SetContent($fileContent)
        }
        else {
            $filePath = [DBOpsConfig]::GetConfigurationFileName()
            $newFile = [DBOpsFile]::new($filePath)
            $newFile.SetContent($fileContent)
            $this.Parent.AddFile($newFile, 'ConfigurationFile')
        }
        [DBOpsHelper]::WriteZipFile($zipFile, $filePath, $fileContent)
    }
    #Initiates package update saving the configuration file in the package
    [void] Alter() {
        #only do something if it's a part of a package
        if ($this.Parent -is [DBOpsPackageBase]) {
            #Open new file stream
            $writeMode = [System.IO.FileMode]::Open
            $stream = $null
            try {
                $stream = [FileStream]::new($this.Parent.FileName, $writeMode, [System.IO.FileAccess]::ReadWrite)
            }
            catch {
                $this.ThrowException("Failed to open filestream to $($this.Parent.FileName) with mode $writeMode", $_)
            }
            try {
                #Open zip file
                $zip = [ZipArchive]::new($stream, [ZipArchiveMode]::Update)
                try {
                    #Write file
                    $this.Save($zip)
                }
                catch { throw $_ }
                finally { $zip.Dispose() }
            }
            catch {
                $this.ThrowException("Failed to modify archive $($this.Parent.FileName)", $_)
            }
            finally { $stream.Dispose()    }

            # Refreshing regular file properties for parent object
            $this.Parent.RefreshFileProperties()
        }
    }
    #Merge two configurations
    [void] Merge([DBOpsConfig]$config) {
        $this.Merge($config.AsHashtable())
    }
    [void] Merge([hashtable]$config) {
        foreach ($key in $config.Keys) {
            if ($key -eq 'Variables') {
                # create new hashtable with all the existing variables
                $hashVar = @{ }
                foreach ($variable in $this.Variables.psobject.Properties.Name) {
                    $hashVar += @{
                        $variable = $this.Variables.$variable
                    }
                }
                # now merge in each incoming value
                if ($config.$key) {
                    if ($config.$key -is [hashtable]) { $variableList = $config.$key.Keys }
                    else { $variableList = $config.$key.psobject.Properties.Name }
                    foreach ($variable in $variableList) {
                        $hashVar.$variable = $config.$key.$variable
                    }
                }
                # lastly, convert back to psobject and re-assign
                $this.SetValue($key, ([pscustomobject]$hashVar))
            }
            else {
                $this.SetValue($key, $config.$key)
            }
        }
    }

    #Save configuration to a file
    [void] SaveToFile([string]$fileName) {
        $this.ExportToJson() | Out-File -FilePath $fileName -Encoding unicode
    }

    #Static Methods
    static [DBOpsConfig] FromJsonString ([string]$jsonString) {
        return [DBOpsConfig]::new($jsonString)
    }
    static [DBOpsConfig] FromFile ([string]$path) {
        if (!(Test-Path $path)) {
            Stop-PSFFunction -EnableException $true -Message "Config file $path not found. Aborting." -FunctionName 'DBOps'
        }
        return [DBOpsConfig]::FromJsonString((Get-Content $path -Raw -ErrorAction Stop))
    }

    static [string] GetPackageFileName () {
        return 'dbops.package.json'
    }

    static [string] GetConfigurationFileName () {
        return 'dbops.config.json'
    }

    static [string[]] EnumProperties () {
        return [DBOps.ConfigProperty].GetEnumNames()
    }

    #Returns deploy file name
    static [object]GetDeployFile() {
        return (Get-DBOModuleFileList | Where-Object { $_.Type -eq 'Misc' -and $_.Name -eq "Deploy.ps1" })
    }
}