src/typesystem/GraphObjectBuilder.ps1
# Copyright 2020, 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 TypeManager) . (import-script TypeDefinition) ScriptClass GraphObjectBuilder { $typeManager = $null $typeDefinition = $null $setDefaultValues = $false $skipPropertyCheck = $false $maxLevel = 0 $propertyFilter = $null $currentLevel = 0 function __initialize([PSTypeName('TypeManager')] $typeManager, [PSTypeName('TypeDefinition')] $typeDefinition, $setDefaultValues, $recurse, [string[]] $propertyFilter, [object[]] $valueList, [HashTable[]] $propertyList, [bool] $skipPropertyCheck ) { $this.typeManager = $typeManager $this.typeDefinition = $typeDefinition $this.setDefaultValues = $setDefaultValues -or $valueList -or ! $typeDefinition.IsComposite $this.skipPropertyCheck = $skipPropertyCheck $values = $valueList $properties = $propertyFilter if ( $propertyList ) { $values = @() $properties = @() foreach ( $table in $propertyList ) { foreach ( $propertyName in $table.keys ) { $properties += $propertyName # The rather awkward way of adding a value to this array is necessitated by the behavior described # in a "WARNING" elsewhere in this file: if you use the += operator to add the value and the value # is an array with one element, it adds the element, rather than the actual array value. This is absolutely # not the desired behavior in this context -- the types used by the caller are to be used literally in # order to deterministically serialize into JSON that meets the API contract where the object being built # by this instance will likely be used. $values += $null # Make space in the array first $values[$values.length -1] = $table[$propertyName] # Then add the element -- a simple assignment preserves the type } } } if ( $properties ) { if ( $valueList -and ( $valueList.length -gt $properties.length ) ) { throw 'Specified list of values has more elements than the specified set of properties' } $this.propertyFilter = @{} for ( $propertyIndex = 0; $propertyIndex -lt $properties.length; $propertyIndex++ ) { $hasValue = $false # WARNING: Be very careful in how the value supplied by the user is handled. PowerShell # has a very strange behavior where a function cannot return a single element array -- the # array gets converted to just the single element! The conditional 'if' statement shares # this behavior. There are various workarounds, though the simplest to understand is to # avoid returning arrays from a function or assigning from an if, try, or other statement. # See https://blog.tyang.org/2011/02/24/powershell-functions-do-not-return-single-element-arrays/. # Ultimately unit tests are the only defense against regressions in this area. $value = $null if ( $values -and ( $propertyIndex -lt $values.length ) ) { $hasValue = $true $value = $values[$propertyIndex] } $this.propertyFilter.Add($properties[$propertyIndex], @{HasValue=$hasValue;Value=$value}) } } $this.maxLevel = if ( $recurse ) { $this.scriptclass.MAX_OBJECT_DEPTH } else { 0 } } # WARNING: We cannot directly return the result of GetPropertyValue from this method # due to an apparent limitation of scriptclass methods. If the return value is a # single element array, it will be returned as just the element, not the array. The workaround # here is to instead return an object that contains the return value. function ToObject([bool] $isCollection = $false) { $this.currentLevel = 0 $value = GetPropertyValue $this.typeDefinition $isCollection $false $null # Returning the value inside this structure ensures that PowerShell # does not turn it into a non-array if it is a single element array [PSCustomObject] @{ Value = $value IsCollection = $isCollection } } function GetPropertyValue($typeDefinition, $isCollection, $useCustomValue, $customValue) { if ( $useCustomValue ) { if ( $customValue -and $customValue.GetType().IsArray -and ( $customValue.length -eq 1 ) ) { return , $customValue } else { return $customValue } } # For any collection, we simply want to provide an empty array or # other default representation of the collection. # WARNING: We must set variables explicitly in if statements here # because if can turn single element arrays into scalars. $propertyValue = $null if ( $isCollection ) { if ( $this.setDefaultValues ) { if ( $typeDefinition.DefaultCollectionValue ) { $propertyValue = , ( . $typeDefinition.DefaultCollectionValue ) } else { $propertyValue = @() } } else { $propertyValue = $null } } else { # For non-collections, we want to embed the value directly in # the parent object if ( $typeDefinition.IsComposite ) { $propertyValue = NewCompositeValue $typeDefinition } else { $propertyValue = NewScalarValue $typeDefinition } } $propertyValue } function NewCompositeValue($typeDefinition) { if ( $this.currentLevel -gt $this.scriptclass.MAX_OBJECT_DEPTH ) { throw "Object depth maximum of '$($this.scriptclass.MAX_OBJECT_DEPTH)' exceeded" } if ( $this.currentLevel -gt $this.maxLevel ) { return $null } $object = @{} $usedProperties = @{} $unusedPropertyCount = 0 try { $this.currentLevel += 1 if ( $this.PropertyFilter -and ( $this.currentLevel -eq 1 ) ) { foreach ( $referencedProperty in $this.PropertyFilter.keys ) { $usedProperties.Add($referencedProperty, $false) $unusedPropertyCount++ } } $typeProperties = $this.typeManager.GetTypeDefinitionTransitiveProperties($typeDefinition) foreach ( $property in $typeProperties ) { $propertyInfo = if ( ( $this.currentLevel -eq 1 ) -and $this.propertyFilter ) { if ( $usedProperties.ContainsKey($property.name) ) { $usedProperties[$property.name] = $true $unusedPropertyCount-- } $this.propertyFilter[$property.name] } if ( ! ( ( $this.currentLevel -eq 1 ) -and $this.propertyFilter ) -or $propertyInfo ) { if ( ( $this.currentLevel -eq 1 ) -and $typeDefinition.Class -eq 'Entity' -and $property.name -eq 'id' ) { continue } $propertyTypeDefinition = $this.typeManager.FindTypeDefinition('Unknown', $property.typeId, $true) if ( ! $propertyTypeDefinition ) { throw "Unable to find type '$($property.typeId)' for property $($property.name) of type $($typeDefinition.typeId)" } $hasValue = $propertyInfo -and $propertyInfo.HasValue $customValue = $null if ( $hasValue ) { $customValue = $propertyInfo.Value } $value = GetPropertyValue $propertyTypeDefinition $property.isCollection $hasValue $customValue $object.Add($property.Name, $value) } } } finally { $this.currentLevel -= 1 } if ( ! $this.skipPropertyCheck -and $unusedPropertyCount -ne 0 ) { $unusedProperties = ( $usedProperties.keys | where { ! $usedProperties[$_] } ) -join ', ' throw "One or more specified properties is not a valid property for type '$($TypeDefinition.name)': '$unusedProperties'" } $object } function NewScalarValue($typeDefinition) { if ( $this.setDefaultValues ) { if ( $typeDefinition.DefaultValue -ne $null ) { if ( $typeDefinition.DefaultValue -is [ScriptBlock] ) { . $typeDefinition.DefaultValue } else { $typeDefinition.DefaultValue } } } else { $null } } static { const MAX_OBJECT_DEPTH 64 $maxLevel = 0 } } |