Let's say you have a giant object - one which may or may not have nested arrays / objects,
# Assuming 'user1' exists in the current domain
$obj = Get-ADUser 'user1' -Properties *
and I want to search that object for the string SMTP
case-insensitively...
What I tried
$obj | Select-String "SMTP"
But it does not work because the match is inside a nested Collection... to be concise, it sits inside the property $obj.proxyAddresses
.
If I run $obj.proxyAddress.GetType()
it returns:
IsPublic IsSerial Name BaseType -------- -------- ---- -------- True False ADPropertyValueCollection System.Collections.CollectionBase
What's the best way to go about this? I know you could loop through the properties and look for it manually using wildcard matching or .Contains()
, but I'd prefer a built in solution.
Thus, it would be a grep
for objects and not only strings.
Here's one solution. It can be very slow depending on what depth you search to; but a depth of 1 or 2 works well for your scenario:
function Find-ValueMatchingCondition {
Param (
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSObject]$InputObject
,
[Parameter(Mandatory = $true)]
[ScriptBlock]$Condition
,
[Parameter()]
[Int]$Depth = 10
,
[Parameter()]
[string]$Name = 'InputObject'
,
[Parameter()]
[System.Management.Automation.PSMemberTypes]$PropertyTypesToSearch = ([System.Management.Automation.PSMemberTypes]::Properties)
)
Process {
if ($InputObject -ne $null) {
if ($InputObject | Where-Object -FilterScript $Condition) {
New-Object -TypeName 'PSObject' -Property @{Name=$Name;Value=$InputObject}
}
#also test children (regardless of whether we've found a match
if (($Depth -gt 0) -and -not ($InputObject.GetType().IsPrimitive -or ($InputObject -is 'System.String'))) {
[string[]]$members = Get-Member -InputObject $InputObject -MemberType $PropertyTypesToSearch | Select-Object -ExpandProperty Name
ForEach ($member in $members) {
$InputObject."$member" | Where-Object {$_ -ne $null} | Find-ValueMatchingCondition -Condition $Condition -Depth ($Depth - 1) -Name $member | ForEach-Object {$_.Name = ('{0}.{1}' -f $Name, $_.Name);$_}
}
}
}
}
}
Get-AdUser $env:username -Properties * `
| Find-ValueMatchingCondition -Condition {$_ -like '*SMTP*'} -Depth 2
Example Results:
Value Name
----- ----
smtp:SomeOne@myCompany.com InputObject.msExchShadowProxyAddresses
SMTP:some.one@myCompany.co.uk InputObject.msExchShadowProxyAddresses
smtp:username@myCompany.com InputObject.msExchShadowProxyAddresses
smtp:some.one@myCompany.mail.onmicrosoft.com InputObject.msExchShadowProxyAddresses
smtp:SomeOne@myCompany.com InputObject.proxyAddresses
SMTP:some.one@myCompany.co.uk InputObject.proxyAddresses
smtp:username@myCompany.com InputObject.proxyAddresses
smtp:some.one@myCompany.mail.onmicrosoft.com InputObject.proxyAddresses
SMTP:some.one@myCompany.mail.onmicrosoft.com InputObject.targetAddress
Find-ValueMatchingCondition
is a function which takes a given object (InputObject
) and tests each of its properties against a given condition, recursively.
The function is divided into two parts. The first part is the testing of the input object itself against the condition:
if ($InputObject | Where-Object -FilterScript $Condition) {
New-Object -TypeName 'PSObject' -Property @{Name=$Name;Value=$InputObject}
}
This says, where the value of $InputObject
matches the given $Condition
then return a new custom object with two properties; Name
and Value
. Name
is the name of the input object (passed via the function's Name
parameter), and Value
is, as you'd expect, the object's value. If $InputObject
is an array, each of the values in the array is assessed individually. The name of the root object passed in is defaulted as "InputObject"
; but you can override this value to whatever you like when calling the function.
The second part of the function is where we handle recursion:
if (($Depth -gt 0) -and -not ($InputObject.GetType().IsPrimitive -or ($InputObject -is 'System.String'))) {
[string[]]$members = Get-Member -InputObject $InputObject -MemberType $PropertyTypesToSearch | Select-Object -ExpandProperty Name
ForEach ($member in $members) {
$InputObject."$member" | Where-Object {$_ -ne $null} | Find-ValueMatchingCondition -Condition $Condition -Depth ($Depth - 1) -Name $member | ForEach-Object {$_.Name = ('{0}.{1}' -f $Name, $_.Name);$_}
}
}
The If
statement checks how deep we've gone into the original object (i.e. since each of an objects properties may have properties of their own, to a potentially infinite level (since properties may point back to the parent), it's best to limit how deep we can go. This is essentially the same purpose as the ConvertTo-Json
's Depth
parameter.
The If
statement also checks the object's type. i.e. for most primitive types, that type holds the value, and we're not interested in their properties/methods (primitive types don't have any properties, but do have various methods, which may be scanned depending on $PropertyTypeToSearch
). Likewise if we're looking for -Condition {$_ -eq 6}
we wouldn't want all strings of length 6; so we don't want to drill down into the string's properties. This filter could likely be improved further to help ignore other types / we could alter the function to provide another optional script block parameter (e.g. $TypeCondition
) to allow the caller to refine this to their needs at runtime.
After we've tested whether we want to drill down into this type's members, we then fetch a list of members. Here we can use the $PropertyTypesToSearch
parameter to change what we search on. By default we're interested in members of type Property
; but we may want to only scan those of type NoteProperty
; especially if dealing with custom objects. See https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.psmembertypes?view=powershellsdk-1.1.0 for more info on the various options this provides.
Once we've selected what members/properties of the input object we wish to inspect, we fetch each in turn, ensure they're not null, then recurse (i.e. call Find-ValueMatchingCondition
). In this recursion, we decrement $Depth
by one (i.e. since we've already gone down 1 level & we stop at level 0), and pass the name of this member to the function's Name
parameter.
Finally, for any returned values (i.e. the custom objects created by part 1 of the function, as outlined above), we prepend the $Name
of our current InputObject to the name of the returned value, then return this amended object. This ensures that each object returned has a Name representing the full path from the root InputObject down to the member matching the condition, and gives the value which matched.