I have the following method declared in a module I've called Common.psm1:
function Test-Any ([ScriptBlock]$FilterScript = $null)
{
begin {
$done = $false
}
process {
if (!$done)
{
if (!$FilterScript -or ($FilterScript | Invoke-Expression)){
$done = $true
}
}
}
end {
$done
}
}
Set-Alias any Test-Any -Scope Global
Now in another module, I have the following validation:
$id = 1
if($notifications | any { $_.Id -eq $id })
{
# do stuff here
return
}
I receive the following error:
Invoke-Expression : The variable '$id' cannot be retrieved because it has not been set.
The interesting thing is that if I move the Test-Any definition to the calling module, it works like a charm.
How can I make this work without copying Test-Any to my other modules and without changing this syntax:
if($notifications | any { $_.Id -eq $id })
EDIT 1: There seems to be some debate about whether or not my code should work. Feel free to try this on your own machine:
function Test-Any ([ScriptBlock]$FilterScript = $null)
{
begin {
$done = $false
}
process {
if (!$done)
{
if (!$FilterScript -or ($FilterScript | Invoke-Expression)){
$done = $true
}
}
}
end {
$done
}
}
Set-Alias any Test-Any -Scope Global
$id = 3
$myArray = @(
@{Id = 1},
@{Id = 2},
@{Id = 3},
@{Id = 4},
@{Id = 5},
@{Id = 6},
@{Id = 7},
@{Id = 8}
)
$myEmptyArray = @()
$myArray | any #returns true
$myArray | any {$_.Id -eq $id} #returns true
$myEmptyArray | any #returns false
$myEmptyArray | any {$_.Id -eq $id} #returns false
EDIT 2:
I just discovered that you only encounter this issue, when Test-Any resides in one loaded module and the calling code resides in a second module using Set-StrictMode -Version Latest. If you turn off StrictMode, you don't get the error, but it also doesn't work.
EDIT 3: Needless to say this works perfectly fine:
$sb = [Scriptblock]::Create("{ `$_.Id -eq $id }")
if($notifications | any $sb)
But seriously takes away from the simplicity and intuitiveness I am trying to obtain
CodePudding user response:
Invoke-Expression (which, when possible, should be avoided) implicitly recreates the script block passed from the caller's scope, via its string representation, in the context of the module, which invalidates any references to the caller's state in the script-block code (because modules generally don't see an outside caller's state, except for the global scope).
The solution is to execute the script block as-is, but provide it pipeline input as passed to the module function:
# Note: New-Module creates a *dynamic* (in-memory only) module,
# but the behavior applies equally to regular, persisted modules.
$null = New-Module {
function Test-Any ([ScriptBlock] $FilterScript)
{
begin {
$done = $false
}
process {
if (!$done)
{
# Note the use of $_ | ... to provide pipeline input
# and the use of ForEach-Object to evaluate the script block.
if (!$FilterScript -or ($_ | ForEach-Object $FilterScript)) {
$done = $true
}
}
}
end {
$done
}
}
}
# Sample call. Should yield $true
$id = 1
@{ Id = 2 }, @{ Id = 1 } | Test-Any { $_.Id -eq $id }
Note: The Test-Any function in this answer uses a similar approach, but tries to optimize processing by stopping further pipeline processing - which, however, comes at the expense of incurring an on-demand compilation penalty the first time the function is called in the session, because - as of PowerShell 7.2 - you cannot (directly) stop a pipeline on demand from user code - see GitHub issue #3821.
