Blauman
Blauman

Reputation: 103

PowerShell: modify elements of array

My cmdlet get-objects returns an array of MyObject with public properties:

public class MyObject{
    public string testString = "test";
}

I want users without programming skills to be able to modify public properties (like testString in this example) from all objects of the array. Then feed the modified array to my second cmdlet which saves the object to the database.

That means the syntax of the "editing code" must be as simple as possible.

It should look somewhat like this:

> get-objects | foreach{$_.testString = "newValue"} | set-objects

I know that this is not possible, because $_ just returns a copy of the element from the array.

So you'd need to acces the elements by index in a loop and then modify the property.This gets really quickly really complicated for people that are not familiar with programming.


Is there any "user-friendly" built-in way of doing this? It shouldn't be more "complex" than a simple foreach {property = value}

Upvotes: 8

Views: 51137

Answers (2)

Χpẘ
Χpẘ

Reputation: 3451

Here's a more general approach, arguably easier to understand, and less fragile:

#  $dataSource  would be get-object in the OP
#  $dataUpdater is the script the user supplies to modify properties
#  $dataSink    would be set-object in the OP
function Update-Data {
  param(
    [scriptblock]   $dataSource,
    [scriptblock]   $dataUpdater,
    [scriptblock]   $dataSink
  )

  & $dataSource |
  % { 
      $updaterOutput = & $dataUpdater
      # This "if" allows $dataUpdater to create an entirely new object, or
      # modify the properties of an existing object
      if ($updaterOutput -eq $null) {
        $_
      } else {
        $updaterOutput
      }
    } |
  % $dataSink
}

Here are a couple of examples of use. The first example isn't applicable to the OP, but it's being used to create a data set that is applicable (a set of objects with properties).

#      Use updata-data to create a set of data with properties
#
$theDataSource = @() # will be filled in by first update-data
update-data {

    # data source
    0..4 
  } { 

    # data updater: creates a new object with properties
    New-Object psobject | 
    # add-member uses hash table created on the fly to add properties
    # to a psobject
    add-member -passthru -NotePropertyMembers @{
               room = @('living','dining','kitchen','bed')[$_];
               size = @(320,     200,     250,      424  )[$_]}
  } {

    # data sink
    $global:theDataSource += $_
  }

$theDataSource  | ft -AutoSize


#      Now use updata-data to modify properties in data set
#      this $dataUpdater updates the 'size' property 
#

$theDataSink = @()
update-data { $theDataSource } { $_.size *= 2} { $global:theDataSink += $_}
$theDataSink | ft -AutoSize

And then the output:

room    size
----    ----
living   320
dining   200
kitchen  250
bed      424

room    size
----    ----
living   640
dining   400
kitchen  500
bed      848

As described above update-data relies on a "streaming" data source and sink. There is no notion of whether the first or fifteenth element is being modified. Or if the data source uses a key (rather than an index) to access each element, the data sink wouldn't have access to the key. To handle this case a "context" (for example an index or a key) could be passed through the pipeline along with the data item. The $dataUpdater wouldn't (necessarily) need to see the context. Here's a revised version with this concept added:

# $dataSource and $dataSink scripts need to be changed to output/input an
# object that contains both the object to modify, as well as the context.
# To keep it simple, $dataSource will output an array with two elements:
# the value and the context. And $dataSink will accept an array (via $_) 
# containing the value and the context.
function Update-Data {
  param(
    [scriptblock]   $dataSource,
    [scriptblock]   $dataUpdater,
    [scriptblock]   $dataSink
  )

  %  $dataSource |
  % { 
      $saved_ = $_
      # Set $_ to the data object
      $_ = $_[0]

      $updaterOutput = & $dataUpdater
      if ($updaterOutput -eq $null) { $updaterOutput = $_}

      $_ = $updaterOutput, $saved_[1]
    } |
  % $dataSink
}

Upvotes: 0

Mathias R. Jessen
Mathias R. Jessen

Reputation: 174485

I know that this is not possible, because $_ just returns a copy of the element from the array (https://social.technet.microsoft.com/forums/scriptcenter/en-US/a0a92149-d257-4751-8c2c-4c1622e78aa2/powershell-modifying-array-elements)

I think you're mis-intepreting the answer in that thread.

$_ is indeed a local copy of the value returned by whatever enumerator you're currently iterating over - but you can still return your modified copy of that value (as pointed out in the comments):

Get-Objects | ForEach-Object {
    # modify the current item
    $_.propertyname = "value"
    # drop the modified object back into the pipeline
    $_
} | Set-Objects

In (allegedly impossible) situations where you need to modify a stored array of objects, you can use the same technique to overwrite the array with the new values:

PS C:\> $myArray = 1,2,3,4,5
PS C:\> $myArray = $myArray |ForEach-Object {
>>>    $_ *= 10
>>>    $_
>>>}
>>>
PS C:\> $myArray
10
20
30
40
50

That means the syntax of the "editing code" must be as simple as possible.

Thankfully, PowerShell is very powerful in terms of introspection. You could implement a wrapper function that adds the $_; statement to the end of the loop body, in case the user forgets:

function Add-PsItem 
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,ValueFromPipeline,ValueFromRemainingArguments)]
        [psobject[]]$InputObject,

        [Parameter(Mandatory)]
        [scriptblock]$Process
    )

    begin {

        $InputArray = @()

        # fetch the last statement in the scriptblock
        $EndBlock = $Process.Ast.EndBlock
        $LastStatement = $EndBlock.Statements[-1].Extent.Text.Trim()

        # check if the last statement is `$_`
        if($LastStatement -ne '$_'){
            # if not, add it
            $Process = [scriptblock]::Create('{0};$_' -f $Process.ToString())
        }
    }

    process {
        # collect all the input
        $InputArray += $InputObject
    }

    end {
        # pipe input to foreach-object with the new scriptblock
        $InputArray | ForEach-Object -Process $Process
    }
}

Now the users can do:

Get-Objects | Add-PsItem {$_.testString = "newValue"} | Set-Objects

The ValueFromRemainingArguments attribute also lets users supply input as unbounded parameter values:

PS C:\> Add-PsItem { $_ *= 10 } 1 2 3
10
20
30

This might be helpful if the user is not used to working with the pipeline

Upvotes: 22

Related Questions