1

I am trying to create a PSCustomObject that has multiple properties that are ArrayLists containing PSCustomObjects. I Am adding the parent object to an ArrayList as well. The idea is to create a relationship that I can easily parse out to yml or another format. For example,

Hosts:                            # Parent ArrayList
  - HostName: myhost              # Start of Parent Object
    FQDN: myhost.my.domain.com
    IPs:                          # ArrayList of Objects, Property of Parent Object
      - IP: 192.168.1.2           # Start of Child Object
        MAC: 00:00:00:00:00:00
      - IP: 10.10.1.1             # Start of Child Object
    Records:                      # ArrayList of Objects, Property of Parent Object
      - Zone: 192.168.1           # Start of Child Object
        RRType: PTR
        Source: 2
        Target: myhost.my.domain.com
        TTL: 2H
      - Zone: my.domain.com       # Start of Child Object
        RRType: A
        Source: myhost.my.domain.com
        Target: 192.168.1.2
        TTL: 2H

The problem I'm having is when I try to add a new object to one of the ArrayList properties of the Parent object, I'm receiving the following error:

Method invocation failed because [System.Management.Automation.PSCustomObject] does not contain a method named 'add'.
At C:\merge.ps1:96 char:13
+             ,$_.DNSRecords.Add($RecordObj)
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (add:String) [], RuntimeException
    + FullyQualifiedErrorId : MethodNotFound

I am using this function to create hosts:

function New-HostEntry {
    param (
        [Parameter(ValueFromPipeline=$true,position=0)]$HostName,
        [Parameter(ValueFromPipeline=$true,position=1)]$FQDN,
        [Parameter(ValueFromPipeline=$true,position=2)]$IP,
        [Parameter(ValueFromPipeline=$true,position=3)]$MAC,
        [Parameter(ValueFromPipeline=$true,position=4)]$RecordObj
    )

    $IPS = New-Object System.Collections.ArrayList
    if ($IP){
        $IPEntry = [PSCustomObject]@{
            IP = $IP
            MAC = $MAC
        }
        $IPS.Add($IPEntry)
    }

    $DNSRecords = New-Object System.Collections.ArrayList
    if ($RecordObj){
        $DNSRecords.Add($RecordObj)
    }

    [PSCustomObject]@{
        HostName = $HostName
        FQDN = $FQDN
        IPs = $IPS
        DNSRecords = $DNSRecords
    }
}

In the main part of the script, I am creating the parent collection & adding hosts to it with the following:

[System.Collections.ArrayList]$hosts= @()
$hosts.add((New-HostEntry -HostName $hostname -FQDN $fqdn -IP $ip -MAC $mac -RecordObj $RecordObj))

Sometimes The values for IP, MAC, or RecordObj may be $null or missing. That does not seem to matter when initially creating the new Host object & adding it to the collection.

Later, an existing Host object (Inside the $Hosts ArrayList) may need to have a $RecordObj added to it:

Add-DNSRecordToHost $_.HostName $RecordObj

This is an example of a RecordObj:

$RecordObj = [PSCustomObject]@{
    Zone = $zonename 
    Source = $Source
    TTL = $TTL
    RRType = $RRType
    Preference = $Preference
    Target = $Target
    Comment = $Comment
}

Finally, this is the function that is failing:

function Add-DNSRecordToHost{
    param (
        [Parameter(ValueFromPipeline=$true,position=0)]$HostName,
        [Parameter(ValueFromPipeline=$true,position=1)]$RecordObj
    )
    $found = $false
    #Get the host with matching HostName
    $hosts | Where-Object {$_.HostName -eq $HostName} | ForEach-Object {
        #Compare each record already on the host to the new record
        Write-host "Checking" $_.HostName "for" $RecordObj.Zone $RecordObj.RRType "record ..."
        foreach ($DNSRecord in $_.DNSRecords){
            if ( -not (Compare-Object $DNSRecord.PSObject.Properties $RecordObj.PSObject.Properties) ) {
                #objects match
                Write-Host "ERROR: Zone Record" $RecordObj.Zone "already exists on " $HostObj.Host -ForegroundColor Yellow
                $found = $true
            }
        }
        #If none of the records match add the new record to the host
        if (!$found){
            Write-Host "ACTION: Adding Zone Record" $RecordObj.Zone "to" $_.HostName -ForegroundColor Cyan
            #Write-Host $DNSRecords.toString()
            $_.DNSRecords.Add($RecordObj)
            #,$_.DNSRecords.Add($RecordObj)
            #$DNSRecords = $_.DNSRecords; $DNSRecords.add($RecordObj); $_.DNSRecords = $DNSRecords
            #$_.DNSRecords | %{ Write-Host $_.Zone $_.Source $_.TTL $_.RRType $_.Preference $_.Target $_.Comment -ForegroundColor Yellow }
        }
    }
}

I have left in (but commented out) my attempts at debugging.

Note: I had also tried passing the function a $host object, but it failed also:

function Add-DNSRecordToHost{
    param (
        [Parameter(ValueFromPipeline=$true,position=0)]$Host,
        [Parameter(ValueFromPipeline=$true,position=1)]$RecordObj
    )
    $found = $false
    foreach ($DNSRecord in $Host.DNSRecords){
        if ( -not (Compare-Object $DNSRecord.PSObject.Properties $RecordObj.PSObject.Properties) ) {
            $found = $true
        }
    }
    if (!$found){
        $Host.DNSRecords.Add($RecordObj)
    }
}

$hosts | % {Add-DNSRecordToHost $_ $RecordObj}

It errors out when I try to add a $RecordObj to the DNSRecords property of a Host object. I Expect the DNSRecords property to be an ArrayList, but the according to the above error message, it has converted to a PSCustomObject. Why is it being changed, and how can I correctly add to it?

Erick
  • 11
  • 3
  • I'm not seeing `$host.DNSRecords` being defined as an `ArrayList`, which probably where the error is coming from. – Santiago Squarzon Jul 02 '21 at 00:49
  • In the function, New-HostEntry, I am creating `$DNSRecords = New-Object System.Collections.ArrayList`, and adding it to the PSCustomObject as the property `DNSRecords = $DNSRecords` Are you suggesting that I initially create the property as an empty array list, add the passed record to it (if it exists), then return the created Host object? – Erick Jul 02 '21 at 00:51
  • I tried creating the host property as an empty ArrayList with `$NewHost= [PSCustomObject]@{DNSRecords = New-Object System.Collections.ArrayList}` and then adding the $RecordObj to it with `if ($RecordObj){NewHost.DNSRecords.Add($RecordObj)}`, and then `return $NewHost`, but I still see the same behavior when trying to add a $RecordObj to it. – Erick Jul 02 '21 at 01:06
  • The error is quite clear, `$host.DNSRecords` is not an `ArrayList` or you shouldn't be having that specific error. If you have doubts you may try `$host.DNSRecords.GetType()` and see for yourself. – Santiago Squarzon Jul 02 '21 at 01:07
  • I completely agree, and I have. I inserted `$_.DNSRecords.GetType()` after `$_.DNSRecords.Add($RecordObj)` (which throws the error), and the GetType() returns as a PSCustomObject. That is exactly my question - If I am creating the DNSRecords property as an empty ArrayList when the $host object is created, why is it changing to a PSCustomObject? – Erick Jul 02 '21 at 01:17

2 Answers2

0

When you are creating the host object, use , to force single-item arrays, otherwise Powershell will unroll the single item in your $DNSRecords ArrayList and initialize your property as a single DNSRecord PSCustomObject

[PSCustomObject]@{
    HostName = $HostName
    FQDN = $FQDN
    IPs = , $IPS
    DNSRecords = , $DNSRecords
}
UPDATE: The above is incorrect in this case as the type ends up being of type Object[] instead of ArrayList. Use the following instead
 [PSCustomObject]@{
        HostName   = $HostName
        FQDN       = $FQDN
        IPs        = $IPS -as [System.Collections.ArrayList]
        DNSRecords = $DNSRecords -as [System.Collections.ArrayList]
    }

Alternatively, you can define a class and explicitly declare the properties as ArrayList

class Host {
    $HostName
    $FQDN
    [System.Collections.ArrayList]$IPs
    [System.Collections.ArrayList]$DNSRecords
}

Then when you instantiate the Host object it can only be an ArrayList and Powershell will not change the type.

New-Object Host -Property @{
    HostName   = $HostName
    FQDN       = $FQDN
    IPs        = $IPS
    DNSRecords = $DNSRecords
}

Off topic, but worth mentioning - when you are adding items to your ArrayLists you'll want to redirect its output to $null so that it does not end up as an item in your outer collection. Either of these will work:

$DNSRecords.Add($RecordObj) | Out-Null
# or
$null = $DNSRecords.Add($RecordObj) 

Edit

Daniel
  • 4,792
  • 2
  • 7
  • 20
  • Thank You!This was exactly the problem, & what I think @santiago-squarzon was getting at in the comments above. I had assumed that because I was assigning the property as an ArrayList that it was later down the pipeline that it was getting translated. Hence, why I had tried using `, $_.DNSRecords.Add($RecordObj)` to add the RecordObj to the DNSRecords property. I clearly misunderstood how that worked. For anyone reading this later, I found more information here: [link](https://stackoverflow.com/questions/59528667/why-does-powershell-convert-an-arraylist-to-a-pscustomobject-when-adding-only-on) – Erick Jul 02 '21 at 14:27
  • @mcgheee please see my update. Using `,` is incorrect in this case. Using `-as [System.Collections.ArrayList]` is the way to go. – Daniel Jul 02 '21 at 14:52
  • I think I see what you mean about using ,. It looked like it was working at first, but when I started exploring the objects that were generated, there was a lot of recursion. Unfortunately, -as [System.Collections.ArrayList] still causes the property to translate to an object rather than an ArrayList. Nevertheless, You put me on the right track with suggesting to use classes & I came up with [this.](https://stackoverflow.com/a/68232038/16361599) – Erick Jul 02 '21 at 23:04
  • Not sure why `-as [System.Collections.ArrayList]` translated to pscustomobject for you. I tested empty, and single item ArrayList in both PS 5.1 and 7.3 and worked perfect for all. – Daniel Jul 02 '21 at 23:13
0

Daniel got me on the right track with his answer, and I ended up solving the problem with custom classes.

See: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.1

I also learned that ArrayLists are deprecated, so I moved to using Generic Lists.

See: https://jameswassinger.com/powershell-using-a-generic-list/

In this simplified version, I created a HostEntry class with an overflow constructor that only takes the HostName & FQDN, and then creates the property NetAddrs as an empty generic list of type [NetAddr], referencing the other class I had created above. Once the HostEntry object is created, I was able to create a NetAddr object, and add it to the NetAddrs collection. This effectively allows me to create custom objects with empty collections as a property.


#Define Class for a Network Address
class NetAddr {
    [ValidatePattern('^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$')][ValidateNotNullorEmpty()][string]$IPAddr
    [AllowNull()][string]$MACAddr
}

#Define Class for Host Entry
class HostEntry {
    $HostName
    $FQDN
    $NetAddrs
    
    HostEntry(
        [string]$HostName,
        [string]$FQDN
    ){
        $this.HostName = $HostName
        $this.FQDN = $FQDN
        $this.NetAddrs = [System.Collections.Generic.List[NetAddr]]::New()
    }

    HostEntry(
        [string]$HostName,
        [string]$FQDN,
        [NetAddr]$NetAddr
    ){
        $this.HostName = $HostName
        $this.FQDN = $FQDN
        $this.NetAddrs = [System.Collections.Generic.List[NetAddr]]::New()
        $this.NetAddrs.add($NetAddr)
    }
}


$hosta = [HostEntry]::New("myhost", "myhost.my.domain.com")
$ipa = [NetAddr]::New()
$ipa.IPAddr = "192.168.1.2"
$ipa.MACAddr = "00:00:00:00:00:00"
$hosta.NetAddrs.Add($ipa)
Erick
  • 11
  • 3
  • I think you took the right direction. I think classes are better for this use case. Also +1 for the Generic List – Daniel Jul 02 '21 at 23:11