src/typesystem/ScalarTypeProvider.ps1

# Copyright 2021, 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.

. (import-script TypeSchema)
. (import-script TypeIndex)

ScriptClass ScalarTypeProvider {
    $base = $null
    $primitiveDefinitions = $null
    $enumerationDefinitions = $null
    $primitiveNames = $null
    $indexes = $null

    function __initialize($graph) {
        $this.indexes = $null

        $this.base = new-so TypeProvider $this $graph

        LoadEnumerationTypeDefinitions
        LoadPrimitiveTypeDefinitions
    }

    function GetTypeDefinition($typeClass, $typeId, $ignoreIfNotFound) {
        $this.scriptclass.ValidateTypeClass($typeClass)

        switch ( $typeClass ) {
            'Primitive' {
                GetPrimitiveDefinition $typeId $ignoreIfNotFound
                break
            }
            'Enumeration' {
                GetEnumerationDefinition $typeId $ignoreIfNotFound
                break
            }
        }
    }

    function GetSortedTypeNames($typeClass) {
        $this.scriptclass.ValidateTypeClass($typeClass)

        switch ( $typeClass ) {
            'Primitive' {
                if ( ! $this.primitiveNames ) {
                    $this.primitiveNames = $this.primitiveDefinitions.keys | sort-object
                }
                $this.primitiveNames
                break
            }
            'Enumeration' {
                $this.enumerationDefinitions.keys
                break
            }
        }
    }

    function GetTypeIndexes([string[]] $indexFields, [string[]] $typeClasses) {
        # Note that we initialize all indexes as well as the only supported
        # type class, even if they are not asked for at the end we do
        # filter for only the requested indexes
        if ( 'Enumeration' -in $typeClasses -and ! $this.indexes ) {
            $nameIndex = new-so TypeIndex Name
            $propertyIndex = new-so TypeIndex Property

            foreach ( $typeId in $this.enumerationDefinitions.Keys ) {
                $enumerationDefinition = $this.enumerationDefinitions[$typeId]
                $nameIndex |=> Add $typeId $typeId Enumeration
                foreach ( $property in $enumerationDefinition.Properties ) {
                    $propertyIndex |=> Add $property.Name.Name $typeId Enumeration
                }
            }

            $this.indexes = $nameIndex, $propertyIndex

            foreach ( $index in $this.indexes ) {
                $index |=> SetContext TypeClass Enumeration
            }
        }

        # Be sure to return only the indexes actually requested
        if ( $this.indexes ) {
            $this.indexes.Values | where IndexedField -in $indexFields
        }
    }

    function UpdateTypeIndexes($indexes, [string[]] $typeClasses) {
        if ( 'Enumeration' -in $typeClasses -and ! $this.indexes ) {
            $nameIndex = $indexes | where IndexedField -eq Name
            $propertyIndex = $indexes | where IndexedField -eq Property

            if ( ! $nameIndex -and ! $propertyIndex ) {
                return
            }

            $indexNames = @()
            $nameIndex, $propertyIndex | where { $_ -ne $null } | foreach { $indexNames += $_.IndexedField }
            $indexNameDescription = $indexNames -join ','

            $activityMessage = "Updating search index(es) '$indexNameDescription' for enumeration types"

            $enumerationCount = ($this.enumerationDefinitions.Keys | measure-object).count
            $enumerationsProcessed = 0

            foreach ( $typeId in $this.enumerationDefinitions.Keys ) {
                if ( $enumerationsProcessed++ % 10 ) {
                    $percent = ( $enumerationsProcessed / $enumerationCount ) * 100
                    Write-Progress -id 1 -activity $activityMessage -PercentComplete $percent
                }
                $enumerationDefinition = $this.enumerationDefinitions[$typeId]
                if( $nameIndex ) {
                    $nameIndex |=> Add $typeId $typeId Enumeration
                }

                if ( $propertyIndex ) {
                    foreach ( $property in $enumerationDefinition.Properties ) {
                        $propertyIndex.Add($property.Name.Name, $typeId, 'Enumeration')
                    }
                }
            }

            Write-Progress -id 1 -activity $activityMessage -Completed
        }
    }

    function LoadEnumerationTypeDefinitions {
        $enumerationDefinitions = [System.Collections.Generic.SortedList[String, Object]]::new()
        $nativeSchemas = $this.base.graph |=> GetEnumTypes

        $nativeSchemas | foreach {
            $properties = [ordered] @{}

            $typeId = $this.base.graph.UnaliasQualifiedName($_.QualifiedName)

            # It turns out some enums have no members (!), so you can't assume
            # that the member property exists -- the schema does not require it
            $enumerationMembers = if ( $_.Schema | Get-Member Member ) {
                $_.Schema.Member
            }

            $enumerationMembers | foreach {
                $memberData = [PSCustomObject] @{
                    Type = 'Edm.String'
                    Name = [PSCUstomObject] @{Name=$_.name;Value=$_.value}
                }

                # TODO: The 'name' field is being misused here -- a previous implementation relied on this structure
                # being in the name field. Now that we are using TypeMember instead of an arbitrary structure, we can
                # just let consumers use the MemberData field and let name just be a name.
                $propertyValue = new-so TypeMember ([PSCUstomObject] @{Name=$_.name;Value=$_.value}) 'Edm.String' $false Enumeration $memberData $typeId
                $properties.Add($_.name, $propertyValue)
            }

            $enumerationValues = $properties.Values
            $defaultValue = if ( $enumerationValues.count -gt 0 ) {
                $enumerationValues | select -first 1 | select -expandproperty name | select -expandproperty name
            }

            $definition = new-so TypeDefinition $typeId Enumeration $_.Schema.name $_.Namespace $null $enumerationValues $defaultValue $null $false $_.Schema
            $enumerationDefinitions.Add($typeId.tolower(), $definition)
        }

        $this.enumerationDefinitions = $enumerationDefinitions
    }

    function LoadPrimitiveTypeDefinitions {
        # See data type documentation at http://docs.oasis-open.org/odata/odata-csdl-json/v4.01/odata-csdl-json-v4.01.html#_Toc26353363
        # for a list of all supported OData primitive types
        $this.primitiveDefinitions = @{
            'Byte' = @{Name='Byte';Type=[byte];DefaultValue={0};DefaultCollectionValue={return , [byte[]]@(0)}}
            'Int16' = @{Name='Int16';Type=[int32];DefaultValue={0};DefaultCollectionValue={return , [int16[]]@(0)}}
            'Int32' = @{Name='Int32';Type=[int32];DefaultValue={0};DefaultCollectionValue={return , [int32[]]@(0)}}
            'Int64' = @{Name='Int64';Type=[int64];DefaultValue={0};DefaultCollectionValue={return , [int64[]]@(0)}}
            'Double' = @{Name='Double';Type=[double];DefaultValue={0};DefaultCollectionValue={return , [double[]]@(0)}}
            'Decimal' = @{Name='Decimal';Type=[double];DefaultValue={0};DefaultCollectionValue={return , [double[]]@(0)}}
            'Single' = @{Name='Single';Type=[single];DefaultValue={0};DefaultCollectionValue={return , [single[]]@(0)}}
            'String' = @{Name='String';Type=[string];DefaultValue={''};DefaultCollectionValue={return , [string[]]@('')}}
            'Boolean' = @{Name='Boolean';Type=[bool];DefaultValue={$false};DefaultCollectionValue={return , [bool[]]@($false)}}
            'Stream' = @{Name='Stream';Type=[byte[]];DefaultValue={[byte[]]@()};DefaultCollectionValue={return , [byte[][]]@([byte[][]]@([byte[]]@()))}}
            'Guid' = @{Name='Guid';Type=[Guid];DefaultValue={([Guid] '00000000-0000-0000-0000-000000000000')};DefaultCollectionValue={return , [Guid[]]@(([Guid] '00000000-0000-0000-0000-000000000000'))}}
            'DateTimeOffset' = @{Name='Date';Type=[DateTimeOffset];DefaultValue={[DateTimeOffset]::new([DateTime]::new([DateTime]::Now.Year, 1, 1))};DefaultCollectionValue={return , @([DateTimeOffset]::new([DateTime]::new([DateTime]::Now.Year, 1, 1)))}}
            'Duration' = @{Name='Duration';Type=[TimeSpan];DefaultValue={[TimeSpan]::new(0)};DefaultCollectionValue={return , [TimeSpan[]]@([TimeSpan]::new(0))}}
            'Binary' = @{Name='Binary';Type=[byte[]];DefaultValue={[byte[]]@()};DefaultCollectionValue={return , [byte[][]]@([byte[][]]@([byte[]]@()))}}
            'Date' = @{Name='Date';Type=[string];DefaultValue={[DateTime]::new(0).tostring("s") + "Z"};DefaultCollectionValue={return , [String[]]@([DateTime]::new(0).tostring("s") + "Z")}}
            'TimeOfDay' = @{Name='TimeOfDay';Type=[string];DefaultValue={[DateTime]::new(0).tostring("s") + "Z"};DefaultCollectionValue={return , [String[]]@([DateTime]::new(0).tostring("s") + "Z")}}
        }
    }

    function GetEnumerationDefinition($typeId, $ignoreIfNotFound) {
        $definition = $this.enumerationDefinitions[$typeId.tolower()]

        if ( ! $definition ) {
            if ( $ignoreIfNotFound ) {
                return
            }
            throw "Enumeration type '$typeId' does not exist"
        }

        $definition
    }

    function GetPrimitiveDefinition($typeId, $ignoreIfNotFound) {
        if ( ! ( $this.scriptclass |=> IsPrimitiveType $typeId ) ) {
            if ( $ignoreIfNotFound ) {
                return
            }
            throw "Type '$typeId' is not a primitive type"
        }

        $nameInfo = $::.TypeSchema |=> GetTypeNameInfo $this.scriptclass.PRIMITIVE_TYPE_NAMESPACE $typeId
        $unqualifiedName = $nameInfo.name

        $nativeSchema = $this.primitiveDefinitions[$unqualifiedName]

        if ( ! $nativeSchema ) {
            if ( $ignoreIfNotFound ) {
                return
            }
            throw "No primitive type '$typeId' exists"
        }

        new-so TypeDefinition $typeId Primitive $nativeSchema.name $this.scriptclass.PRIMITIVE_TYPE_NAMESPACE $null $null $nativeSchema.DefaultValue $nativeSchema.DefaultCollectionValue $false $nativeSchema
    }

    static {
        const PRIMITIVE_TYPE_NAMESPACE Edm

        function GetTypeProvider($graph) {
            $::.TypeProvider |=> GetTypeProvider $this $graph
        }

        function IsPrimitiveType($typeId) {
            $primitivePrefix = $this.PRIMITIVE_TYPE_NAMESPACE + '.'
            $typePrefix = $typeId.substring(0, $primitivePrefix.length)
            $typePrefix -eq $primitivePrefix
        }

        function GetSupportedTypeClasses {
            @('Primitive', 'Enumeration')
        }

        function GetDefaultNamespace($typeClass, $graph) {
            if ( $typeClass -eq 'Primitive' ) {
                $this.PRIMITIVE_TYPE_NAMESPACE
            } else {
                $graph |=> GetDefaultNamespace
            }
        }

        function ValidateTypeClass($typeClass) {
            $::.TypeProvider.ValidateTypeClass($this, $typeClass)
        }
    }
}