1

(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).

Uber Kluger
  • 394
  • 1
  • 11

1 Answers1

2

Re 1:

No, it is a design flaw in Set-Content (or, more generally in PowerShell's parameter binder, depending on your vantage point) that hasn't yet been fixed in PowerShell (Core) v6+, as of the version current as of this writing, v7.3.0.

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

  • Note: This limited fix works with files, i.e. the file-system provider, and any other providers that either expect strings as content values or can convert from strings, but there's no guarantee that this applies to all providers.

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.[1]

In other words: What constitutes the design flaw is that, as of PowerShell 7.3.0, 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.

  • A - technically breaking - potential future change to PowerShell's parameter binder could offer a solution: The parameter binder could initially take the exact type-matching step out of the picture for [object] / [object[]] parameters[1] - given that it is meaningless - and use a property with a matching name, if present, and only fall back to whole-object binding if not.

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.3.0, 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 - limited - 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.


[1] Actually, the order of the rules appears to be incorrect: Exact type matches are considered first: first by checking the input object's type as a whole, then a name-matching property's type (for parameters declared with both ValueFromPipeline and ValueFromPipelineByPropertyName). Only then is binding by type conversion attempted, in the same order. Also, a type match is considered exact if the input type is either of the very same type as the parameter or if it is of a type derived from the parameter type. The ultimate source of truth is the source code.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Could you tell me why are you referencing the module `Microsoft.PowerShell.Management` in the function ? – Santiago Squarzon Jan 20 '22 at 02:59
  • 1
    @Santiago: That is necessary in order to call the _original_ `Set-Content` cmdlet, which the function _shadows_ by using the same name. If you didn't use the module qualifier, you'd end up with infinite recursion. – mklement0 Jan 20 '22 at 03:04
  • 1
    Omg right! Makes total sense. I didn't know you could reference a function from a module with that syntax, thanks! – Santiago Squarzon Jan 20 '22 at 03:06
  • If `-Value` were `[String[]]`, would not step 4 (match piped value by conversion) occur before steps 5 or 6, since every object can be converted using `Object.ToString()`. Your workaround is tested correct so is parameter evaluation different for functions or maybe the description by Bruce Payette incomplete (i.e. not all possible conversions are tried). Also, for clarity, my analysis suggested that the flaw is not `Set-Content` but PowerShell parameter binding itself and would apply to any cmdlet with an `[Object]/[Object[]] (ValueFromPipeline, ValueFromPipelineByPropertyName)` parameter. No? – Uber Kluger Jan 21 '22 at 04:13
  • How breaking would it be to swap steps 3,4 and 5,6 so that object members with matching names are always tried first (if `ValueFromPipelineByPropertyName` is true). All parameter types or (kludgey) only `[Object]/[Object[]]` parameters maybe? – Uber Kluger Jan 21 '22 at 04:25
  • @UberKluger, yeah, I think the description cited from Bruce's book isn't quite correct. I haven't dug too deep, but I've tried to summarize what I think are the correct rules in a footnote I've just added to the answer, along with a link to the source code, if you want to dig deeper. Yes, the behavior is intrinsic to the parameter binder itself, but it is defensible. From that vantage point, the flaw is in `Set-Content` insofar as it chose an unsupported combination of parameter type and attribute properties. – mklement0 Jan 22 '22 at 20:12
  • @UberKluger, as for a potential change: It's hard to say what the real-world impact of making the breaking change you suggest would be; if I were to guess, little existing code would break, but they've been very reluctant to make any breaking changes. You could argue that limiting a change to `[object]` / `[object[]]` is the right (non-kludgy) thing to do: take the exact type-matching step out of the picture (because it is meaningless) and go by the presence of a property with a matching name only. That way, you wouldn't _change the order of_ checks, but simply _omit_ inapplicable ones. – mklement0 Jan 22 '22 at 20:31
  • @UberKluger, if you feel inspired, I encourage you to open an [issue on GitHub](https://github.com/PowerShell/PowerShell/issues) - but I wouldn't be too hopeful. – mklement0 Jan 22 '22 at 20:33