Home > other >  Set-Content -Value parameter treats piped object as ValueFromPipeline (and converts it to string) ev
Set-Content -Value parameter treats piped object as ValueFromPipeline (and converts it to string) ev

Time:01-22

(PS 5.1.18362.145) The parameter definition for Set-Content -Value is

Position                        : 1
ParameterSetName                : __AllParameterSets
Mandatory                       : True
ValueFromPipeline               : True
ValueFromPipelineByPropertyName : True
ValueFromRemainingArguments     : False
HelpMessage                     :
HelpMessageBaseName             :
HelpMessageResourceId           :
DontShow                        : False
TypeId                          : System.Management.Automation.ParameterAttribute

TypeId : System.Management.Automation.AllowNullAttribute

TypeId : System.Management.Automation.AllowEmptyCollectionAttribute

Importantly, both ValueFromPipeline and ValueFromPipelineByPropertyName are true. However, tests show that ValueFromPipelineByPropertyName is ineffective.

# Example 1
PS > [PSCustomObject]@{Value="frad"},
>> [PSCustomObject]@{Value="fred"},
>> [PSCustomObject]@{Value="frid"} | Set-Content testfile.txt
PS > Get-Content testfile.txt
@{value=frad}
@{value=fred}
@{value=frid}

However, -Path is specified as

ValueFromPipeline               : False
ValueFromPipelineByPropertyName : True

Testing ValueFromPipelineByPropertyname produces the following

# Example 2
PS > [PSCustomObject]@{Path="frad.txt"},
>> [PSCustomObject]@{Path="fred.txt"},
>> [PSCustomObject]@{Path="frid.txt"} | Set-Content -Value teststring
PS > Get-Item *

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       20/01/2022   6:15 AM             12 frad.txt
-a----       20/01/2022   6:15 AM             12 fred.txt
-a----       20/01/2022   6:15 AM             12 frid.txt
-a----       20/01/2022   6:09 AM             45 testfile.txt
PS > Get-Content f*
teststring
teststring
teststring

And if we do both

# Example 3
PS > [PSCustomObject]@{Path="frad.txt";Value="frad"},
>> [PSCustomObject]@{Path="fred.txt";Value="fred"},
>> [PSCustomObject]@{Path="frid.txt";Value="frid"} | Set-Content
PS > Get-Item *

Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       20/01/2022   6:21 AM             30 frad.txt
-a----       20/01/2022   6:21 AM             30 fred.txt
-a----       20/01/2022   6:21 AM             30 frid.txt
-a----       20/01/2022   6:09 AM             45 testfile.txt

PS > Get-Content f*
@{Path=frad.txt; Value=frad}
@{Path=fred.txt; Value=fred}
@{Path=frid.txt; Value=frid}

So clearly, the Path property is effective but the Value property is not. An explanation (from SO 25550299) using an excerpt from Windows Powershell in Action By Bruce Payette sheds some light.

...

  1. Bind from the pipeline by value with exact match- If the command is not the first command in the pipeline and there are still unbound parameters that take pipeline input, try to bind to a parameter that matches the type exactly.

  2. If not bound, then bind from the pipe by value with conversion. - If the previous step failed, try to bind using a type conversion.

  3. If not bound, then bind from the pipeline by name with exact match - If the previous step failed, look for a property on the input object that matches the name of the parameter. If the types exactly match, bind the parameter.

  4. If not bound, then bind from the pipeline by name with conversion. If the input object has a property whose name matches the name of a parameter, and the type of the property is convertible to the type of the parameter, bind the parameter.

The problem with this could be that the type of the -Value parameter is Object[], thus any object will already be an exact match (step 3) and so existence of the Value property will not be checked. This then makes it purposeless to have both ValueFromPipeline and ValueFromPipelineByPropertyName set to true for an Object (or Object[]) parameter.

This analysis would seem to be supported by the following

PS > Trace-Command -Name ParameterBinding -PSHost -Expression {
>> [PSCustomObject]@{Path="blotto.txt";Value="blotto"} | Set-Content
>> }
DEBUG: ParameterBinding Information: 0 : BIND NAMED cmd line args [Set-Content]
DEBUG: ParameterBinding Information: 0 : BIND POSITIONAL cmd line args [Set-Content]
DEBUG: ParameterBinding Information: 0 : BIND cmd line args to DYNAMIC parameters.
DEBUG: ParameterBinding Information: 0 :     DYNAMIC parameter object:
[Microsoft.PowerShell.Commands.FileSystemContentWriterDynamicParameters]
DEBUG: ParameterBinding Information: 0 : MANDATORY PARAMETER CHECK on cmdlet [Set-Content]
DEBUG: ParameterBinding Information: 0 : CALLING BeginProcessing
DEBUG: ParameterBinding Information: 0 : BIND PIPELINE object to parameters: [Set-Content]
DEBUG: ParameterBinding Information: 0 :     PIPELINE object TYPE = [System.Management.Automation.PSCustomObject]
DEBUG: ParameterBinding Information: 0 :     RESTORING pipeline parameter's original values
DEBUG: ParameterBinding Information: 0 :     Parameter [Value] PIPELINE INPUT ValueFromPipeline NO COERCION
DEBUG: ParameterBinding Information: 0 :     BIND arg [@{path=blotto.txt; value=blotto}] to parameter [Value]
DEBUG: ParameterBinding Information: 0 :         Binding collection parameter Value: argument type [PSObject],
parameter type [System.Object[]], collection type Array, element type [System.Object], no coerceElementType
DEBUG: ParameterBinding Information: 0 :         Creating array with element type [System.Object] and 1 elements
DEBUG: ParameterBinding Information: 0 :         Argument type PSObject is not IList, treating this as scalar
DEBUG: ParameterBinding Information: 0 :         Adding scalar element of type PSObject to array position 0
DEBUG: ParameterBinding Information: 0 :         BIND arg [System.Object[]] to param [Value] SUCCESSFUL
DEBUG: ParameterBinding Information: 0 :     Parameter [Credential] PIPELINE INPUT ValueFromPipelineByPropertyName NO
COERCION
DEBUG: ParameterBinding Information: 0 :     Parameter [Path] PIPELINE INPUT ValueFromPipelineByPropertyName NO
COERCION
DEBUG: ParameterBinding Information: 0 :     BIND arg [blotto.txt] to parameter [Path]
DEBUG: ParameterBinding Information: 0 :         Binding collection parameter Path: argument type [String], parameter
type [System.String[]], collection type Array, element type [System.String], no coerceElementType
DEBUG: ParameterBinding Information: 0 :         Creating array with element type [System.String] and 1 elements
DEBUG: ParameterBinding Information: 0 :         Argument type String is not IList, treating this as scalar
DEBUG: ParameterBinding Information: 0 :         Adding scalar element of type String to array position 0
DEBUG: ParameterBinding Information: 0 :         BIND arg [System.String[]] to param [Path] SUCCESSFUL
DEBUG: ParameterBinding Information: 0 :     Parameter [Credential] PIPELINE INPUT ValueFromPipelineByPropertyName WITH
 COERCION
DEBUG: ParameterBinding Information: 0 :     Parameter [Credential] PIPELINE INPUT ValueFromPipelineByPropertyName WITH
 COERCION
DEBUG: ParameterBinding Information: 0 : MANDATORY PARAMETER CHECK on cmdlet [Set-Content]
DEBUG: ParameterBinding Information: 0 : CALLING EndProcessing
PS > type blotto.txt
@{path=blotto.txt; value=blotto}

As can be seen, the -Value parameter is successfully bound, without coercion, to the input object as ValueFromPipeline (i.e. step 3).

The questions then are,

  1. has this been fixed since PS 5.1 (assuming that others agree that it is a bug)?
  2. is there a way to utilise the Value property of a piped object to Set-Content (or any property where the corresponding cmdlet parameter can receive an [Object]) without losing access to the remainder of the object (in versions prior to any fix)? (e.g. $object.Value | Set-Content ... doesn't work, see example 3 above)

I can't check question 1 myself (system limitations), otherwise I might have raised this as a PowerShell issue on GitHub. And, yes, I know that example 3 is a somewhat contrived, esoteric edge case but example 1 is much more likely (and trying to do 3 as part of a test setup was how I found this).

CodePudding user response:

Re 1:

No, it is a design flaw in Set-Content that hasn't yet been fixed in PowerShell (Core) v6 , as of the version current as of this writing, v7.2.1

A fix would require re-typing the -Value parameter from [object[]] to [string[]] (see the proof of concept in the bottom section).

That way, a non-string input object with a .Value property would be bound by that property (ValueFromPipelineByPropertyValue) without getting preempted by the [object[]]-typed parameter that's also declared to bind objects as a whole (ValueFromPipeline), given that the latter binds any input object as a whole first (because all objects in .NET derive from [object]), per the binding rules cited in your question.

In other words: What constitutes the design flaw is that it makes no sense to declare an [object] or [object[]-typed parameter as both ValueFromPipeline and ValueFromPipelineByPropertyValue, because only the ValueFromPipeline behavior will ever take effect.

Re 2:

No, unfortunately not.

It would also require the fix suggested above, because even using a delay-bind script-block parameter doesn't help in this case, due to the -Value parameter's [object[]] type.

# !! Does NOT work as of v7.2.1, because delay-bind script blocks
# !! only work with parameters typed *other* than [object] or [scriptblock]
# !! Currently, *verbatim* ' $_.Value ' is used, i.e.
# !! the immediate *stringification* of the script block.
[PSCustomObject]@{Path="frad.txt";Value="frad"} | 
  Set-Content -Value { $_.Value }

Without the suggested fix in place, you'll have to resort to an - inefficient - workaround: pipe the objects to ForEach-Object and call Set-Content there, using each input object's properties explicitly.


Here's a simplified proof of concept for the fix:

function Set-Content {
  [CmdletBinding()]
  param(
    [Parameter(ValueFromPipelineByPropertyName)]
    [string[]] $Path,
    [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [string[]] $Value # Note the type of [string[]] rather than [object[]]
  )
  process {
    foreach ($p in $Path) {
      # Delegate to the original Set-Content, with explicit arguments.
      Microsoft.PowerShell.Management\Set-Content -Path $p -Value $Value
    }
  }
}

With the above Set-Content override in place:

[PSCustomObject]@{Path="frad.txt";Value="frad"},
[PSCustomObject]@{Path="fred.txt";Value="fred"} | Set-Content

creates file frad.txt with content frad, and file fred.txt with content fred, as expected.

  •  Tags:  
  • Related