3

Background

I have a data object in PowerShell with 4 properties, 3 of which are strings and the 4th a hashtable. I would like to arrange for a new type that is defined as a collection of this data object.

In this collection class, I wish to enforce a particular format that will make my code elsewhere in the module more convenient. Namely, I wish to override the add method with a new definition, such that unique combinations of the 3 string properties add the 4th property as a hashtable, while duplicates of the 3 string properties simply update the hashtable property of the already existing row with the new input hashtable.

This will allow me to abstract the expansion of the collection and ensure that when the Add method is called on it, it will retain my required format of hashtables grouped by unique combinations of the 3 string properties.

My idea was to create a class that extends a collection, and then override the add method.

Code so far

As a short description for my code below, there are 3 classes:

  1. A data class for a namespace based on 3 string properties (which I can reuse in my script for other things).
  2. A class specifically for adding an id property to this data class. This id is the key in a hashtable with values that are configuration parameters in the namespace of my object.
  3. A 3rd class to handle a collection of these objects, where I can define the add method. This is where I am having my issue.
Using namespace System.Collections.Generic

Class Model_Namespace {
    [string]$Unit
    [string]$Date
    [string]$Name

    Model_Namespace([string]$unit, [string]$date, [string]$name) {
        $this.Unit = $unit
        $this.Date = $date
        $this.Name = $name
    }
}

Class Model_Config {
    [Model_Namespace]$namespace
    [Hashtable]$id
    
    Model_Config([Model_Namespace]$namespace, [hashtable]$config) {
        $this.namespace = $namespace
        $this.id = $config
    }
    Model_Config([string]$unit, [string]$date, [string]$name, [hashtable]$config) {
        $this.namespace = [Model_Namespace]::new($unit, $date, $name)
        $this.id = $config
    }
}

Class Collection_Configs {
    $List = [List[Model_Config]]@()

    [void] Add ([Model_Config]$newConfig ){
        $checkNamespaceExists = $null

        $u  = $newConfig.Unit
        $d  = $newConfig.Date
        $n  = $newConfig.Name
        $id = $newConfig.id

        $checkNamespaceExists = $this.List | Where { $u -eq $_.Unit -and $d -eq $_.Date -and $n -eq $_.Name }

        If ($checkNamespaceExists){
            ($this.List | Where { $u -eq $_.Unit -and $d -eq $_.Date -and $n -eq $_.Name }).id += $id
        }
        Else {
            $this.List.add($newConfig)
        }
    }
}

Problem

I would like the class Collection_Configs to extend a built-in collection type and override the Add method. Like a generic List<> type, I could simply output the variable referencing my collection and automatically return the collection. This way, I won't need to dot into the List property to access the collection. In fact I wouldn't need the List property at all.

However, when I inherit from System.Array, I need to supply a fixed array size in the constructor. I'd like to avoid this, as my collection should be mutable. I tried inheriting from List, but I can't get the syntax to work; PowerShell throws a type not found error.

Is there a way to accomplish this?

Update

After mklement's helpful answer, I modified the last class as:

Using namespace System.Collections.ObjectModel

Class Collection_Configs : System.Collections.ObjectModel.Collection[Object]{

    [void] Add ([Model_Config]$newConfig ){
        $checkNamespaceExists = $null

        $newConfigParams = $newConfig.namespace
        $u  = $newConfigParams.Unit
        $d  = $newConfigParams.Date
        $n  = $newConfigParams.Name
        $id = $newConfig.id

        $checkNamespaceExists = $this.namespace | Where { $u -eq $_.Unit -and $d -eq $_.Date -and $n -eq $_.Name }

        If ($checkNamespaceExists){
            ($this | Where { $u -eq $_.namespace.Unit -and $d -eq $_.namespace.Date -and $n -eq $_.namespace.Name }).id += $id
        }
        Else {
            ([Collection[object]]$this).add($newConfig)
        }
    }
}

Which seems to work. In addition to the inheritance, had to do some other corrections regarding how I dotted into my input types, and I also needed to load the collection class separately after the other 2 classes as well as use the base class's add method in my else statement.

Going forward, I will have to do some other validation to ensure that a model_config type is entered. Currently the custom collection accepts any input, even though I auto-convert the add parameter to model_config, e.g.,

$config = [model_config]::new('a','b','c',@{'h'='t'})
$collection = [Collection_Configs]::new()
$collection.Add($config)

works, but

$collection.Add('test')

also works when it should fail validation. Perhaps it is not overriding correctly and using the base class's overload?

Last update

Everything seems to be working now. The last update to the class is:

using namespace System.Collections.ObjectModel

Class Collection_Configs : Collection[Model_Config]{

    [void] Add ([Model_Config]$newConfig ){
        $checkNamespaceExists = $null
        
        $namespace = $newConfig.namespace
        $u  = $namespace.Unit
        $d  = $namespace.Date
        $n  = $namespace.Name
        $id = $newConfig.id

        $checkNamespaceExists = $this.namespace | Where { $u -eq $_.Unit -and $d -eq $_.Date -and $n -eq $_.Name }

        If ($checkNamespaceExists){
            ($this | Where { $u -eq $_.namespace.Unit -and $d -eq $_.namespace.Date -and $n -eq $_.namespace.Name }).id += $id
        }
        Else {
            [Collection[Model_Config]].GetMethod('Add').Invoke($this, [Model_Config[]]$newConfig)
        }
    }
}

Notice in the else statement that ....GetMethod('Add')... is necessary for Windows PowerShell, as pointed out in the footnote of mklement0's super useful and correct answer. If you are able to work with Core, then mklement0's syntax will work (I tested).

Also mentioned by mklement0, the types need to be loaded separately. FYI this can be done on the commandline for quick provisional testing by typing in the model_namespace and model_config classes and pressing enter before doing the same for Collection_Configs.

In summary this will create a custom collection type with custom methods in PowerShell.

Blaisem
  • 557
  • 8
  • 17
  • Your code looks good to me, I don't see a point on reinventing the wheel and having a powershell class that does exactly the same as what `List` can do but worse – Santiago Squarzon Dec 31 '22 at 14:14
  • @SantiagoSquarzon because if I use List, then I have to define the custom add method's logic elsewhere. Every time the list is instantiated and added to, I will need a separate line to run this logic. If that separate line is forgotten, no error message will appear. If that person who forgets isn't me, then they may not understand why it isn't working. Which means I will need to add more boilerplate everywhere to explicitly validate that my collection's format is being maintained. Easier to just have a custom collection that guarantees proper format and sticks to the conventional add method – Blaisem Jan 02 '23 at 10:54

2 Answers2

3

It is possible to subclass System.Collections.Generic.List`1, as this simplified example, which derives from a list with [regex] elements, demonstrates:[1]

using namespace System.Collections.Generic

# Subclass System.Collections.Generic.List`1 with [regex] elements.
class Collection_Configs : List[regex] {

    # Override the .Add() method.
    # Note: You probably want to override .AddRange() too.
    Add([regex] $item) {
      Write-Verbose -Verbose 'Doing custom things...'
      # Call the base-class method.
      ([List[regex]] $this).Add($item)
    }

  }

# Sample use.
$list = [Collection_Configs]::new()
$list.Add([regex] 'foo')
$list

However, as you note, it is recommended to derive custom collections from base class System.Collections.ObjectModel.Collection`1:

using namespace System.Collections.ObjectModel

# Subclass System.Collections.ObjectModel`1 with [regex] elements.
class Collection_Configs : Collection[regex] {

    # Override the .Add() method.
    # Note: Unlike with List`1, there is no .AddRange() method.
    Add([regex] $item) {
      Write-Verbose -Verbose 'Doing custom things...'
      # Call the base-class method.
      ([Collection[regex]] $this).Add($item)
    }

  }

As for the pros and cons:

  • List`1 has more built-in functionality (methods) than ObjectModel`1, such as .Reverse(), Exists(), and .ForEach().

  • In the case of .ForEach() that actually works to the advantage of ObjectModel`1: not having such a method avoids a clash with PowerShell's intrinsic .ForEach() method.

Note that in either case it is important to use the specific type that your collection should be composed of as the generic type argument for the base class: [regex] in the example above, [Model_Config] in your real code (see next section).

  • If you use [object] instead, your collection won't be type-safe, because it'll have a void Add(object item) method that PowerShell will select whenever you call the .Add() method with an instance of a type that is not the desired type (or cannot be converted to it).

However, there's an additional challenge in your case:

  • As of PowerShell 7.3.1, because the generic type argument that determines the list element type is another custom class, that other class must unexpectedly be loaded beforehand, in a separate script, the script that defines the dependent Collection_Configs class.

    • This requirement is unfortunate, and at least conceptually related to the general (equally unfortunate) need to ensure that .NET types referenced in class definitions have been loaded before the enclosing script executes - see this post, whose accepted answer demonstrates workarounds.

    • However, given that all classes involved are part of the same script file in your case, a potential fix should be simpler than the one discussed in the linked post - see GitHub issue #18872.


[1] Note: There appears to be a bug in Windows PowerShell, where calling the base class' .Add() method fails if the generic type argument (element type) happens to be [pscustomobject] aka [psobject]: That is, while ([List[pscustomobject]] $this).Add($item) works as expected in PowerShell (Core) 7+, an error occurs in Windows PowerShell, which requires the following reflection-based workaround: [List[pscustomobject]].GetMethod('Add').Invoke($this, [object[]] $item)

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Thanks as usual mklement. This put me on the right path. My script had some other inaccuracies that I will update into it. I read in the course of my research that according to Microsoft Docs, custom collections (at least in C#) should inherit from the Collections.ObjectModel.Collection type, so this is what I did. Is there a reason to prefer the Collections.Generic.List type? – Blaisem Jan 01 '23 at 13:30
  • I also noticed I can still add any type to the collection, even though my Add method should attempt to autoconvert the input argument to Model_Config. Maybe it is still using the base class's Add overload? I updated my answer to show this. If this is the case, then I guess a true override would not be possible with PowerShell? – Blaisem Jan 01 '23 at 13:42
  • I should also note that I ran this in Windows PowerShell 5.1.22621, and the base class add method seems to be correctly called when I auto convert to the base class first (see my else block in updated answer). However, I used the object type instead of psobject. Perhaps that is a possible workaround? – Blaisem Jan 01 '23 at 13:48
  • 1
    @Blaisem, please see my update. In short: you _must_ use `[Model_Config]` as the generic type argument in the type you derive from, in order to get a type-safe collection; and if you want to use `[Model_Config]`, you must place your `[Collection_Configs]` class in a separate script that you dot-source after having defined `[Model_Config]` (in a pinch, you can define it via `Invoke-Expression` - see the linked post). – mklement0 Jan 01 '23 at 21:06
-2

There were a few issues with the original code:

The Using keyword was spelled incorrectly. It should be using. The $List variable in the Collection_Configs class was not declared with a type. It should be [List[Model_Config]]$List. The Add method in the Collection_Configs class was missing its return type. It should be [void] Add ([Model_Config]$newConfig). The Add method was missing its opening curly brace.

  • None of the suggested changes are necessary and amount to a distraction. Even if they were relevant, they should be a _comment_ instead (you'll be able to comment once you gain 15 reputation points). – mklement0 Dec 31 '22 at 18:29