193

I'm using Powershell to set up IIS bindings on a web server, and having a problem with the following code:

$serverIps = gwmi Win32_NetworkAdapterConfiguration 
    | Where { $_.IPAddress } 
    | Select -Expand IPAddress 
    | Where { $_ -like '*.*.*.*' } 
    | Sort

if ($serverIps.length -le 1) {
    Write-Host "You need at least 2 IP addresses for this to work!"
    exit
}

$primaryIp = $serverIps[0]
$secondaryIp = $serverIps[1]

If there's 2+ IPs on the server, fine - Powershell returns an array, and I can query the array length and extract the first and second addresses just fine.

Problem is - if there's only one IP, Powershell doesn't return a one-element array, it returns the IP address (as a string, like "192.168.0.100") - the string has a .length property, it's greater than 1, so the test passes, and I end up with the first two characters in the string, instead of the first two IP addresses in the collection.

How can I either force Powershell to return a one-element collection, or alternatively determine whether the returned "thing" is an object rather than a collection?

Dylan Beattie
  • 53,688
  • 35
  • 128
  • 197

10 Answers10

217

Define the variable as an array in one of two ways...

Wrap your piped commands in parentheses with an @ at the beginning:

$serverIps = @(gwmi Win32_NetworkAdapterConfiguration 
    | Where { $_.IPAddress } 
    | Select -Expand IPAddress 
    | Where { $_ -like '*.*.*.*' } 
    | Sort)

Specify the data type of the variable as an array:

[array]$serverIps = gwmi Win32_NetworkAdapterConfiguration 
    | Where { $_.IPAddress } 
    | Select -Expand IPAddress 
    | Where { $_ -like '*.*.*.*' } 
    | Sort

Or, check the data type of the variable...

IF ($ServerIps -isnot [array])
{ <error message> }
ELSE
{ <proceed> }
JNK
  • 63,321
  • 15
  • 122
  • 138
  • 54
    Wrapping a command in `@(...)` will return an array even if there are zero objects. Whereas assigning the result to an `[Array]`-typed variable will still return $null if there are zero objects. – Nic Nov 20 '13 at 17:16
  • 4
    Just a note that none of these solutions work if the object being returned is a PSObject (possibly others). – Deadly-Bagel May 12 '16 at 12:59
  • 2
    @Deadly-Bagel Can you show example of this? For me `@(...)` work properly (produce result I expect it should produce) for any types of objects. – user4003407 Jun 17 '16 at 07:44
  • 3
    Funny how you end up back on the same questions. I had (and have again) a slightly different problem, yes as in the question this works fine but when returning from a function it's a different story. If there's one element, the array is ignored and only the element is returned. If you put a comma before the variable it forces it to an array but a multi-element array will then return a two-dimensional array. Very tedious. – Deadly-Bagel Jun 22 '17 at 08:57
  • @Deadly-Bagel can you give an example? – JNK Jun 23 '17 at 17:14
  • 3
    Gah, this is what happened last time too, now I can't replicate it. At any rate I solved my recent problem by using `Return ,$out` which seems to always work. If I run into the problem again I'll post an example. – Deadly-Bagel Jun 27 '17 at 08:29
  • If I do this, will I end up creating a nested array instead? – tyteen4a03 Nov 08 '17 at 11:29
  • `@(dir | Select -First 1 Name) | ConvertTo-Json` is not an array (`{...}` instead of `[...]`). Just sayin... – Marc Feb 19 '19 at 19:25
18

Force the result to an Array so you could have a Count property. Single objects (scalar) do not have a Count property. Strings have a length property so you might get false results, use the Count property:

if (@($serverIps).Count -le 1)...

By the way, instead of using a wildcard that can also match strings, use the -as operator:

[array]$serverIps = gwmi Win32_NetworkAdapterConfiguration -filter "IPEnabled=TRUE" | Select-Object -ExpandProperty IPAddress | Where-Object {($_ -as [ipaddress]).AddressFamily -eq 'InterNetwork'}
Shay Levy
  • 121,444
  • 32
  • 184
  • 206
17

You can either add a comma(,) before return list like return ,$list or cast it [Array] or [YourType[]] at where you tend to use the list.

Luckybug
  • 686
  • 8
  • 6
  • just cast to array. so simple. awesome! best answer! this effing comma haunted me. – Chris Oct 04 '21 at 10:19
  • In a case where I'm always trying to return an array from a function, even if there's only one element, I'm only having success using the comma `,` (unary array operator). – Max Cascone Dec 01 '21 at 21:28
  • 1
    So, what's the difference between ,(...), @(...) and [array](...). In the following example - $strArray|%{ $_ -split ' ' } - the only option that returns a list of arrays is $strArray|%{ ,($_ -split ' ') }. I'm running Powershell 5 – Grasshopper Dec 21 '21 at 08:15
  • IMHO, this is the answer to the question. It's the only way I've been able to actually return a value of type `System.Array`. All the other answers demonstrate how to convert the results to an array. – Slogmeister Extraordinaire Aug 12 '22 at 13:24
10

If you declare the variable as an array ahead of time, you can add elements to it - even if it is just one...

This should work...

$serverIps = @()

gwmi Win32_NetworkAdapterConfiguration 
    | Where { $_.IPAddress } 
    | Select -Expand IPAddress 
    | Where { $_ -like '*.*.*.*' } 
    | Sort | ForEach-Object{$serverIps += $_}
JNK
  • 63,321
  • 15
  • 122
  • 138
Kyle Neier
  • 109
  • 2
  • I actually feel like this is the most clear and safe option. You can reliably use ".Count - ge 1' on the collection or 'Foreach' – Jaigene Kang Jul 11 '17 at 22:24
  • 1
    Building arrays incrementally is bad practice as a new array is created and filled each cycle. This is dreadfully inefficient with large arrays. The current preferred option is to use a typed list object and .Add() $List = [System.Collections.Generic.List[string]]::new() # OR $list = New-Object System.Collections.Generic.List[string] $List.Add("Test1") – jim birch Jan 14 '22 at 01:23
3

You can use Measure-Object to get the actual object count, without resorting to an object's Count property.

$serverIps = gwmi Win32_NetworkAdapterConfiguration 
    | Where { $_.IPAddress } 
    | Select -Expand IPAddress 
    | Where { $_ -like '*.*.*.*' } 
    | Sort

if (($serverIps | Measure).Count -le 1) {
    Write-Host "You need at least 2 IP addresses for this to work!"
    exit
}
Patrick
  • 138
  • 4
  • The `Measure-Object` is unnecessary. Use the `$serverIps.Count` just works. – Will Huang Jul 08 '22 at 16:25
  • 1
    The `Count` property is not available if there's only a single object. Which was the entire point of the original question. `Measure-Object` will guarantee a valid `Count` property, with multiple, single, or even $null objects. – Patrick Jul 09 '22 at 17:36
  • As I tested, every object has a `.Count` property. `function Get1 { return @('Hello') }; (Get1).GetType(); (Get1).Count`. – Will Huang Jul 10 '22 at 21:33
  • 1
    I realized only PowerShell Core support `.Count` on almost every objects. – Will Huang Jul 11 '22 at 10:25
0

I had this problem passing an array to an Azure deployment template. If there was one object, PowerShell "converted" it to a string. In the example below, $a is returned from a function that gets VM objected according to the value of a tag. I pass the $a to the New-AzureRmResourceGroupDeployment cmdlet by wrapping it in @(). Like so:

$TemplateParameterObject=@{
     VMObject=@($a)
}

New-AzureRmResourceGroupDeployment -ResourceGroupName $RG -Name "TestVmByRole" -Mode Incremental -DeploymentDebugLogLevel All -TemplateFile $templatePath -TemplateParameterObject $TemplateParameterObject -verbose

VMObject is one of the template's parameters.

Might not be the most technical / robust way to do it, but it's enough for Azure.


Update

Well the above did work. I've tried all the above and some, but the only way I have managed to pass $vmObject as an array, compatible with the deployment template, with one element is as follows (I expect MS have been playing again (this was a report and fixed bug in 2015)):

[void][System.Reflection.Assembly]::LoadWithPartialName("System.Web.Extensions")
    
    foreach($vmObject in $vmObjects)
    {
        #$vmTemplateObject = $vmObject 
        $asJson = (ConvertTo-Json -InputObject $vmObject -Depth 10 -Verbose) #-replace '\s',''
        $DeserializedJson = (New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer -Property @{MaxJsonLength=67108864}).DeserializeObject($asJson)
    }

$vmObjects is the output of Get-AzureRmVM.

I pass $DeserializedJson to the deployment template' parameter (of type array).

For reference, the lovely error New-AzureRmResourceGroupDeployment throws is

"The template output '{output_name}' is not valid: The language expression property 'Microsoft.WindowsAzure.ResourceStack.Frontdoor.Expression.Expressions.JTokenExpression' 
can't be evaluated.."
Community
  • 1
  • 1
woter324
  • 2,608
  • 5
  • 27
  • 47
0

Return as a referenced object, so it never converted while passing.

return @{ Value = @("single data") }
masato
  • 9
  • 1
0

There is a way to deal with your situation. Leave most of you code as-is, just change the way to deal with the $serverIps object. This code can deal with $null, only one item, and many items.

$serverIps = gwmi Win32_NetworkAdapterConfiguration 
    | Where { $_.IPAddress } 
    | Select -Expand IPAddress 
    | Where { $_ -like '*.*.*.*' } 
    | Sort

# Always use ".Count" instead of ".Length".
# This works on $null, only one item, or many items.
if ($serverIps.Count -le 1) {
    Write-Host "You need at least 2 IP addresses for this to work!"
    exit
}

# Always use foreach on a array-possible object, so that
# you don't have deal with this issue anymore.
$serverIps | foreach {
    # The $serverIps could be $null. Even $null can loop once.
    # So we need to skip the $null condition.
    if ($_ -ne $null) {
        # Get the index of the array.
        # The @($serverIps) make sure it must be an array.
        $idx = @($serverIps).IndexOf($item)

        if ($idx -eq 0) { $primaryIp = $_ }
        if ($idx -eq 1) { $secondaryIp = $_ }
    }
}

In PowerShell Core, there is a .Count property exists on every objects. In Windows PowerShell, there are "almost" every object has an .Count property.

Will Huang
  • 2,955
  • 2
  • 37
  • 90
  • 1
    Note this does not work with `Set-StrictMode -Version Latest` because then `$null` does not have `Count` – stijn Aug 24 '22 at 07:42
0

Here's what's going on in PowerShell when you are using the pipeline with arrays.

Take, for example, this case:

@(
    @('single-element-array') | 
        ForEach-Object { "$_".Trim() } |
        Sort-Object -Unique
) | ConvertTo-Json

The result will simply be:

"single-element-array"

First, that code above looks silly, but, assume that you had queried for some data and the result was a single element array, whose element may consist of whitespace or is $null, and you wanted to ensure that the value collapses to '', hence the "$_".Trim(). OK, with that out of the way, the resulting JSON is probably not what one would expect coming from other programming languages such as C#.

Much as described by the other answers, since this expression flows through a pipeline, the same rules as described by others apply, and the resulting object is "unwrapped".

Typically, a function implements a pipeline as follows:

function Some-Function {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [object[]] $InputObject
    )

    Begin {
        # This block is optional
    }

    Process {
        # This block is optional, too, but is required
        # to process pipelined objects

        foreach ($o in $Object) {
            # do something
        }
    }

    End {
        # This block is optional IF a Process block
        # is defined.

        # If no Begin, Process, or End blocks are defined,
        # the code after the parameters is assumed to be
        # an End block, which effectively turns the function
        # into a PowerShell 2.0 filter function.

        # When data is piped into this function, and a Process
        # block exists, the value of $InputObject in this block
        # is the very last item that was piped in.
        # In cases where the pipeline is not being used, $InputObject
        # is the value of the parameter as passed to the function.
    }
}

To use the above:

$Stuff | Some-Function

In a function like this, using the pipeline, values are processed serially. Each value in $Stuff is piped into the function. If the function has a Begin block, that runs once, before any value is processed from parameters passed in via the pipeline, i.e. other non-pipelined parameter values are available in the Begin block. The Process block is then executed once for each value pipelined in. Finally, if the function has an End block, that is run at the very end after all values piped in have been processed.

So, why use a foreach statement in the Process block? To handle when the function is called thusly:

Some-Function -InputObject $Stuff

In this invocation, the entire array of $Stuff is passed by parameter to the function. So, in order to process the data correctly, a foreach loop in the Process block is used. And this should now give you the information you need to know how to circumvent the problem in my first example. In order for that example to run correctly, it needs to be done as follows:

ConvertTo-JSON -InputObject @(
    @('single-element-array') |
        ForEach-Object { "$_".Trim() } |
        Sort-Object -Unique
)

And this results in the expected JSON:

[
  "single-element-array"
]

Armed with this knowledge, to fix your code so that it works as you expect, you should do the following:

# Get-WmiInstance is deprecated in favor of GetCimInstance
# It's generally a good idea to avoid aliases--they can be redefined!
# That could result in unexpected results.
# But the big change here is forcing the output into an array via @()
$serverIps = @(
    Get-CimInstance Win32_NetworkAdapterConfiguration 
        | Where-Object { $_.IPAddress } 
        | Select-Object -ExpandProperty IPAddress 
        | Where-Object { $_ -like '*.*.*.*' } 
        | Sort-Object
)

# Don't use length. In certain circumstances (esp. strings), Length
# can give unexpected results. PowerShell adds a "synthetic property"
# to all "collection objects" called Count. Use that instead, as
# strings (oddly enough, since they are IEnumerbale<Char>) don't get a
# Count synthetic property. :/
if ($serverIps.Count -le 1) {
    Write-Host "You need at least 2 IP addresses for this to work!"
    exit
}

Hope that helps to clarify the issue.

fourpastmidnight
  • 4,032
  • 1
  • 35
  • 48
0

To force a function to return an array (no matter the size of the collection), I do this:

return Write-Output $array -NoEnumerate 
Nova
  • 38
  • 6