3

I have a PowerShell script that runs every hour, it essentially updates product info through a drip process. The script is failing in the dev environment, but continues to work in prod and qual. The failure point is at a line that uses LINQ. Here is the script:

$productInfos = [pscustomobject]@{
    product_name = 'Television'
    price = 1000
    product_id = 101
    } | ForEach-Object{$_}

$productIdDelegate = [Func[object,int]] {$args[0].product_id}
$productInfos = [Linq.Enumerable]::ToLookup($productInfos, $productIdDelegate)

Technically, it fails on the last line. but the second to last line is included because while researching this issue, I found a lot of other posts that pointed out if the two arguments in the ToLookup linq function are not of the same type, it can cause this issue. The second to last line was the "supposed" fix for many users, but sadly not myself. I still receive the

Cannot find an overload for "ToLookup" and the argument count: "2"

I checked any dependencies on all 3 environments, and everything is the same. And, the powershell versions are the same on all as well.

Ransome
  • 101
  • 6
  • Without a [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) I don't see how we could possibly help you – Santiago Squarzon Jul 10 '22 at 19:37
  • @SantiagoSquarzon You make a good point, my apologies. I have added code that will produce the error I receive. – Ransome Jul 10 '22 at 19:59
  • 2
    Well, you need to ensure that `$productInfos` will always be an array, for that you can use `@( ... )` to wrap the expression, i.e.: `$productInfos = @( [pscustomobject]@{ .... } ... )` or as another alternative, `[Linq.Enumerable]::ToLookup(@( $productInfos ), $productIdDelegate)` – Santiago Squarzon Jul 10 '22 at 20:06
  • 1
    @SantiagoSquarzon, Gotcha, that is really odd. I tried many different ways, such as casting the object as an array, all to no avail. Your solution worked, Thank you! – Ransome Jul 10 '22 at 20:17

2 Answers2

3

Even though the error doesn't give much details, from your currently reproducible code we can tell that the issue is because $productInfos is a single object of the type PSObject and this class does not implement IEnumerable Interface:

$productInfos = [pscustomobject]@{
    product_name = 'Television'
    price = 1000
    product_id = 101
}

$productInfos -is [System.Collections.IEnumerable] # => False

To ensure $productInfos is always an enumberable you can use the Array subexpression operator @( ):

@($productInfos) -is [System.Collections.IEnumerable] # => True

Casting [object[]] or [array] should also be an option:

[object[]] $productInfos -is [System.Collections.IEnumerable] # => True
[array] $productInfos -is [System.Collections.IEnumerable]    # => True

However above methods only work as long as $productInfos has at least 1 element, as opposed to @(..) which will always ensure an array:

The result is always an array of 0 or more objects.

To summarize:

$productInfos = [Linq.Enumerable]::ToLookup([object[]] $productInfos, $productIdDelegate)
$productInfos = [Linq.Enumerable]::ToLookup(@($productInfos), $productIdDelegate)
$productInfos = [Linq.Enumerable]::ToLookup([array] $productInfos, $productIdDelegate)

Are all valid options having what's explained above in consideration, if we're not sure if the variable will always be populated, the safest alternative will always be @(...):

# Returns Null instead of an error:

[Linq.Enumerable]::ToLookup(@(), $productIdDelegate)
Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37
2

A note up front:

... | ForEach-Object{ $_ }, with a single input object, as in your case, never outputs an array (unless the input object is an array as a whole, which is atypical), even if the input was a single-element array, because of the pipeline's enumeration behavior. In fact, the pipeline itself never creates arrays on output, it streams objects one by one, and it is only when that stream is captured that an array must be created - but only for two or more output objects; see this answer for background information.


Santiago Squarzon has provided the crucial pointer: wrapping the first argument passed to [System.Linq.Enumerable]::ToLookup[TSource, TKey]() in an [object[]] array allows PowerShell to find the right overload of and type arguments for this method:

$productInfos = [Linq.Enumerable]::ToLookup(
  @($productInfos),   # wrap $productInfos in an [object[]] array with @(...)
  $productIdDelegate
)

Like all LINQ methods, ToLookup:

  • operates on enumerables (loosely speaking, collections of objects),
  • is a generic method, which means that it must be instantiated with the types it operates on (type arguments that bind to the method's type parameters).

PowerShell has to infer the type arguments in your call, because you're not providing the type arguments explicitly, the way you'd need to in C# (e.g., Array.Empty<int>())

  • Note: Prior to the upcoming verion 7.3 of PowerShell (Core), PowerShell didn't even support specifying type arguments explicitly - see next section.

The signature of the ToLookup overload you're targeting is (you can see this by executing [Linq.Enumerable]::ToLookup, i.e. without (), though the following has been simplified and reformatted for readability):

# TSource and TKey are the generic type parameters.
static ... ToLookup[TSource, TKey](
  IEnumerable[TSource] source, 
  Func[TSource,TKey] keySelector
)

Since the argument you're passing to the keySelector parameter, $productIdDelegate, is an instance of [Func[object,int]], PowerShell can infer that the argument for type parameter TSource is [object], and for TKey it is [int].

Since TSource is [object], the argument you pass to the source parameter must be of type IEnumerable[object].

Your $productInfos value, due to being a single object, does not implement the IEnumerable[object] interface, but an [object[]]-typed array does.

Given that @(...), the array-subexpression operator, always returns an [object[]]-typed array, it is sufficient to use it to wrap $productInfos.

Note that had you used a specific type in lieu of [object], e.g. [Func[datetime, int]], @(...) would not work: you would then need a type-specific cast, such as [datetime[]] in this example.
Conversely, in your case you could have used [object[]] $productInfos (or its equivalent, [Array] $productInfos) as an alternative to @($productInfos).

See also:

  • This answer for an overview of using LINQ from PowerShell, which has its limitations, because PowerShell even in 7.3 won't support extension methods (yet?), which are necessary for a fluid experience.

  • Adding support for extension methods, and possibly also an equivalent of LINQ query syntx - is being discussed for a future version of PowerShell (Core) in GitHub issue #2226.


PowerShell 7.3+ alternative:

Versions 7.3+ have - optional - support for specifying type arguments explicitly, which is good news for two reasons:

  • The new syntax makes a given method call clearly communicate the type arguments it uses.

  • The new syntax makes it possible to directly call generic methods where the type arguments can not be inferred.

    • For instance, this applies to parameter-less generic methods, which previously required cumbersome workarounds; see this post
    • A simple example is the static System.Array.Empty[T] method, which you can now call as [Array]::Empty[int]()

If you apply this syntax to your call, you may omit casting your script block to [Func[object, int]], because this cast is implied by the explicit type parameters; using a simplified, self-contained version of your call:

# PS v7.3+ only

# Note the explicit type arguments ([object, int])
# and the use of the script block without a [Funct[object, int]] cast.
$lookup = [Linq.Enumerable]::ToLookup[object, int](
  @([pscustomobject] @{ foo = 1 } , [pscustomobject] @{ foo = 2 }), 
  { $args[0].foo }
)

$lookup[2] # -> [pscustomobject] @{ foo = 2 }

As in the case of inferred type arguments, if the first type argument is something other than [object], say [pscustomobject], you'd need a type-specific array cast (@(...) alone won't be enough):

# PS v7.3+ only

# Note the [pscustomobject] type argument and the
# [pscustomobject[]] cast.
$lookup = [Linq.Enumerable]::ToLookup[pscustomobject, int](
  [pscustomobject[]] ([pscustomobject] @{ foo = 1 } , [pscustomobject] @{ foo = 2 }), 
  { $args[0].foo }
)
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Was busy after your comment, the new syntax for `Linq` does look nice doesn't it :P – Santiago Squarzon Jul 12 '22 at 19:55
  • 1
    Indeed, Santiago, though note that for the full LINQ experience another piece of the puzzle is missing: support for extension methods and/or something like LINQ's query syntax - see https://github.com/PowerShell/PowerShell/issues/2226 – mklement0 Jul 12 '22 at 21:24