src/scriptobject/common/NativeObjectBuilder.ps1

# Copyright 2019, Adam Edwards
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# The NativeObjectBuilder allows the consumer to construct objects in an object format
# "native" to another runtime, in this case the PowerShell runtime. As much as possible
# runtime-specific, i.e. PowerShell-specific object behaviors are centralized in this
# class.
enum NativeObjectBuilderMode {
    Create
    Modify
}

class NativeObjectBuilder {
    static $NativeTypeMemberName = 'PSTypeName'
    NativeObjectBuilder([string] $typeName, [PSCustomObject] $prototype, [NativeObjectBuilderMode] $mode) {
        $this.mode = $mode
        $this.object = if ( $mode -eq [NativeObjectBuilderMode]::Modify ) {
            if ( ! $prototype ) {
                throw [ArgumentException]::New("'Modify' mode was specified for ObjectBuilder, but no existing object was specified to modify")
            }

            if ( $typeName ) {
                throw [ArgumentException]::new("Type name may not be specified for ObjectBuilder 'Modify' mode -- an object's type is immutable")
            }

            $prototype
        } else {
            $objectState = @{}
            if ( $typeName ) {
                $this::UnregisterClassType($typeName)
                $objectState[$this::NativeTypeMemberName] = $typeName
            }
            if ( $prototype ) {
                $prototype.psobject.properties | foreach {
                    if ( $_.membertype -ne 'NoteProperty' ) {
                        throw [ArgumentException]::new("Property '$($_.name)' of member type '$($_.memberType)' is not of valid member type 'NoteProperty'")
                    }

                    $objectState[$_.name] = $_.value
                }
            }

            [PSCustomObject] $objectState
        }
    }

    [PSCustomObject] GetObject() {
        return $this.object
    }

    [void] AddMember($name, $memberType, $value, $secondValue) {
        $secondValueParameter = if ( $secondValue ) {
            @{SecondValue=$secondValue}
        } else {
            @{}
        }

        $this.object | add-member -MemberType $memberType -name $name -value $value @secondValueParameter
    }

    [void] AddProperty($name, $type, $value, $isConstant) {
        $backingPropertyName = if ( ! $type -and ! $isConstant) {
            $name
        } else {
            # Check to make sure any initializer is compatible with the declared type
            if ($type -and ($value -ne $null)) {
                $evalString = "param(`[$type] `$value)"
                $evalBlock = [ScriptBlock]::Create($evalString)
                (. $evalBlock $value) | out-null
            }
            "___$($name)"
        }

        $this.AddMember($backingPropertyname, 'NoteProperty', $value, $null)

        if ( $type -or $isConstant ) {
            $typeCoercion = if ( $type ) {
                # We *MUST* use the FullName property here to avoid short type name collisions or other
                # non-determinism stemming from the fact that ToString() for a given type is not required to
                # return any valid type name (short, full, or anything else) -- FullName is required to do so however.
                # An example is the type for [ordered]@{} which for an unknown reason resolves to the string "ordered"
                # in some versions of PowerShell which is not a type.
                "[$($type.FullName)]"
            } else {
                ''
            }
            $readBlock = [ScriptBlock]::Create("$typeCoercion `$this.$backingPropertyName")
            $writeBlock = if ( ! $isConstant ) {
                [Scriptblock]::Create("param(`$val) `$this.$backingPropertyName = $typeCoercion `$val")
            } else {
                [Scriptblock]::Create("param(`$val) throw `"member '$name' cannot be overwritten because it is read-only`"")
            }
            $this.AddMember($name, 'ScriptProperty', $readBlock, $writeBlock)
        }
    }

    [void] AddMethod($name, $methodBlock) {
        $this.AddMember($name, 'ScriptMethod', $methodBlock, $null)
    }

    [void] RemoveMember([string] $name, [string] $type, [bool] $force) {
        if ( ! $force ) {
            if (! ( $this.object | gm -name $name -membertype $type -erroraction ignore ) ) {
                throw "Member '$name' cannot be removed because the object has no such member."
            }
        }
        $this.object.psobject.members.remove($name)
    }

    static [object] CopyFrom($sourceObject) {
        return $sourceObject.psobject.copy()
    }

    hidden static [void] UnregisterClassType([string] $typeName) {
        remove-typedata $typename -erroraction ignore
    }

    static [void] RegisterClassType([string] $typeName, [string[]] $visiblePropertyNames, $prototype) {
        if ( $visiblePropertyNames -contains ([NativeObjectBuilder]::NativeTypeMemberName) ) {
            throw "Property name ([NativeObjectBuilder]::NativeTypeMemberName) is prohibited"
        }

        $typeArguments = [NativeObjectBuilder]::basicTypeData.clone()
        $typeArguments.TypeName = $typeName

        $displayProperties = @{}
        $typeArguments['DefaultDisplayPropertySet'] |
          where { $_ -ne $null -and $_ -ne ([NativeObjectBuilder]::NativeTypeMemberName) } |
          foreach {
              $displayProperties.Add($_, $null)
          }

        if ( $visiblePropertyNames ) {
            $visiblePropertyNames | foreach {
                $displayProperties.Add($_, $null)
            }
        }

        if ( $displayProperties.count -gt 0 ) {
            $typeArguments['DefaultDisplayPropertySet'] = [string[]] $displayProperties.keys
        }

        if ( $prototype ) {
            $typeArguments['PropertySerializationSet'] = $prototype.psobject.properties | where name -ne ([NativeObjectBuilder]::NativeTypeMemberName) | select -expandproperty name
        }

        Update-TypeData -force @typeArguments
    }

    static $basicTypeData = @{
        TypeName = $null
        Serializationmethod = 'SpecificProperties'
        Serializationdepth = 2
    }

    [PSCustomObject] $object = $null
    [NativeObjectBuilderMode] $mode
}