Make Select-Object/ForEach-Object/Where-Object see dynamic properties (#6898)

Dynamic (DLR) objects work in some places today, but not others.  This change expands that support to ForEach-Object, Where-Object and the family of cmdlets that use 'MshExpression' (Select-Object, etc.).

This change addresses both wildcard and non-wildcard cases. In wildcard cases, it uses the existing support of generating PSDynamicMember objects for names returned by GetDynamicMemberNames.

In non-wildcard cases, a dynamic property access is attempted whether or not the name shows up in GetDynamicMemberNames, but a truly "blind" access is only attempted if we see that the base object is an IDMOP. If the dynamic access fails, you'll get the same or a similar error experience as before ("The property 'Blarg' cannot be found", or no error at all, depending on the cmdlet and the strict mode setting).

The included test coverage includes a stub for the.ForEach operator--once people are happy with this change, I can continue by adding support there.

This change should allegedly also have positive perf impact, though in actual perf testing, although it does seem ever-so-slightly faster, I found it difficult to measure much difference at all.
This commit is contained in:
Dan Thompson
2018-06-12 23:48:34 -07:00
committed by Dongbo Wang
parent 3a5a3e4c6e
commit ce3cb68551
7 changed files with 415 additions and 89 deletions
@@ -4,9 +4,11 @@
using System;
using System.Management.Automation;
using System.Management.Automation.Internal;
using System.Management.Automation.Language;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
namespace Microsoft.PowerShell.Commands.Internal.Format
{
@@ -123,13 +125,20 @@ namespace Microsoft.PowerShell.Commands.Internal.Format
{
// get the members first: this will expand the globbing on each parameter
members = target.Members.Match(_stringValue,
PSMemberTypes.Properties | PSMemberTypes.PropertySet);
PSMemberTypes.Properties | PSMemberTypes.PropertySet | PSMemberTypes.Dynamic);
}
else
{
// we have no globbing: try an exact match, because this is quicker.
PSMemberInfo x = target.Members[_stringValue];
if ((x == null) && (target.BaseObject is System.Dynamic.IDynamicMetaObjectProvider))
{
// We could check if GetDynamicMemberNames includes the name... but
// GetDynamicMemberNames is only a hint, not a contract, so we'd want
// to attempt the binding whether it's in there or not.
x = new PSDynamicMember(_stringValue);
}
List<PSMemberInfo> temp = new List<PSMemberInfo>();
if (x != null)
{
@@ -165,10 +174,14 @@ namespace Microsoft.PowerShell.Commands.Internal.Format
}
}
}
continue;
}
// it can be a property
if (member is PSPropertyInfo)
else if (member is PSPropertyInfo)
{
temporaryMemberList.Add(member);
}
// it can be a dynamic member
else if (member is PSDynamicMember)
{
temporaryMemberList.Add(member);
}
@@ -225,11 +238,13 @@ namespace Microsoft.PowerShell.Commands.Internal.Format
#region Private Members
private CallSite<Func<CallSite, object, object>> _getValueDynamicSite;
private MshExpressionResult GetValue(PSObject target, bool eatExceptions)
{
try
{
object result;
object result = null;
if (Script != null)
{
@@ -243,12 +258,16 @@ namespace Microsoft.PowerShell.Commands.Internal.Format
}
else
{
PSMemberInfo member = target.Properties[_stringValue];
if (member == null)
if (_getValueDynamicSite == null)
{
return new MshExpressionResult(null, this, null);
_getValueDynamicSite =
CallSite<Func<CallSite, object, object>>.Create(
PSGetMemberBinder.Get(
_stringValue,
classScope: (Type) null,
@static: false));
}
result = member.Value;
result = _getValueDynamicSite.Target.Invoke(_getValueDynamicSite, target);
}
return new MshExpressionResult(result, this, null);
@@ -2,6 +2,8 @@
// Licensed under the MIT License.
using System;
using System.Dynamic;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using System.Text;
@@ -16,6 +18,38 @@ using Dbg = System.Management.Automation.Diagnostics;
namespace Microsoft.PowerShell.Commands
{
/// <summary>
/// A thin wrapper over a property-getting Callsite, to allow reuse when possible.
/// </summary>
struct DynamicPropertyGetter
{
private CallSite<Func<CallSite, object, object>> _getValueDynamicSite;
// For the wildcard case, lets us know if we can reuse the callsite:
private string _lastUsedPropertyName;
public object GetValue(PSObject inputObject, string propertyName)
{
Dbg.Assert(!WildcardPattern.ContainsWildcardCharacters(propertyName), "propertyName should be pre-resolved by caller");
// If wildcards are involved, the resolved property name could potentially
// be different on every object... but probably not, so we'll attempt to
// reuse the callsite if possible.
if (!propertyName.Equals(_lastUsedPropertyName, StringComparison.OrdinalIgnoreCase))
{
_lastUsedPropertyName = propertyName;
_getValueDynamicSite = CallSite<Func<CallSite, object, object>>.Create(
PSGetMemberBinder.Get(
propertyName,
classScope: (Type) null,
@static: false));
}
return _getValueDynamicSite.Target.Invoke(_getValueDynamicSite, inputObject);
}
}
#region Built-in cmdlets that are used by or require direct access to the engine.
/// <summary>
@@ -136,6 +170,7 @@ namespace Microsoft.PowerShell.Commands
}
private string _propertyOrMethodName;
private string _targetString;
private DynamicPropertyGetter _propGetter;
/// <summary>
/// The arguments passed to a method invocation
@@ -351,80 +386,107 @@ namespace Microsoft.PowerShell.Commands
member = _inputObject.Members[_propertyOrMethodName];
}
if (member == null)
// member is a method
if (member is PSMethodInfo)
{
errorRecord = GenerateNameParameterError("Name", InternalCommandStrings.PropertyOrMethodNotFound,
"PropertyOrMethodNotFound", _inputObject,
_propertyOrMethodName);
}
else
{
// member is a method
if (member is PSMethodInfo)
{
// first we check if the member is a ParameterizedProperty
PSParameterizedProperty targetParameterizedProperty = member as PSParameterizedProperty;
if (targetParameterizedProperty != null)
{
// should process
string propertyAction = String.Format(CultureInfo.InvariantCulture,
InternalCommandStrings.ForEachObjectPropertyAction, targetParameterizedProperty.Name);
// ParameterizedProperty always take parameters, so we output the member.Value directly
if (ShouldProcess(_targetString, propertyAction))
{
WriteObject(member.Value);
}
return;
}
PSMethodInfo targetMethod = member as PSMethodInfo;
Dbg.Assert(targetMethod != null, "targetMethod should not be null here.");
try
{
// should process
string methodAction = String.Format(CultureInfo.InvariantCulture,
InternalCommandStrings.ForEachObjectMethodActionWithoutArguments, targetMethod.Name);
if (ShouldProcess(_targetString, methodAction))
{
if (!BlockMethodInLanguageMode(InputObject))
{
object result = targetMethod.Invoke(Utils.EmptyArray<object>());
WriteToPipelineWithUnrolling(result);
}
}
}
catch (PipelineStoppedException)
{
// PipelineStoppedException can be caused by select-object
throw;
}
catch (Exception ex)
{
MethodException mex = ex as MethodException;
if (mex != null && mex.ErrorRecord != null && mex.ErrorRecord.FullyQualifiedErrorId == "MethodCountCouldNotFindBest")
{
WriteObject(targetMethod.Value);
}
else
{
WriteError(new ErrorRecord(ex, "MethodInvocationError", ErrorCategory.InvalidOperation, _inputObject));
}
}
}
// member is a property
else
// first we check if the member is a ParameterizedProperty
PSParameterizedProperty targetParameterizedProperty = member as PSParameterizedProperty;
if (targetParameterizedProperty != null)
{
// should process
string propertyAction = String.Format(CultureInfo.InvariantCulture,
InternalCommandStrings.ForEachObjectPropertyAction, member.Name);
InternalCommandStrings.ForEachObjectPropertyAction, targetParameterizedProperty.Name);
// ParameterizedProperty always take parameters, so we output the member.Value directly
if (ShouldProcess(_targetString, propertyAction))
{
WriteObject(member.Value);
}
return;
}
PSMethodInfo targetMethod = member as PSMethodInfo;
Dbg.Assert(targetMethod != null, "targetMethod should not be null here.");
try
{
// should process
string methodAction = String.Format(CultureInfo.InvariantCulture,
InternalCommandStrings.ForEachObjectMethodActionWithoutArguments, targetMethod.Name);
if (ShouldProcess(_targetString, methodAction))
{
if (!BlockMethodInLanguageMode(InputObject))
{
object result = targetMethod.Invoke(Utils.EmptyArray<object>());
WriteToPipelineWithUnrolling(result);
}
}
}
catch (PipelineStoppedException)
{
// PipelineStoppedException can be caused by select-object
throw;
}
catch (Exception ex)
{
MethodException mex = ex as MethodException;
if (mex != null && mex.ErrorRecord != null && mex.ErrorRecord.FullyQualifiedErrorId == "MethodCountCouldNotFindBest")
{
WriteObject(targetMethod.Value);
}
else
{
WriteError(new ErrorRecord(ex, "MethodInvocationError", ErrorCategory.InvalidOperation, _inputObject));
}
}
}
else
{
string resolvedPropertyName = null;
bool isBlindDynamicAccess = false;
if (member == null)
{
if ((_inputObject.BaseObject is IDynamicMetaObjectProvider) &&
!WildcardPattern.ContainsWildcardCharacters(_propertyOrMethodName))
{
// Let's just try a dynamic property access. Note that if it
// comes to depending on dynamic access, we are assuming it is a
// property; we don't have ETS info to tell us up front if it
// even exists or not, let alone if it is a method or something
// else.
//
// Note that this is "truly blind"--the name did not show up in
// GetDynamicMemberNames(), else it would show up as a dynamic
// member.
resolvedPropertyName = _propertyOrMethodName;
isBlindDynamicAccess = true;
}
else
{
errorRecord = GenerateNameParameterError("Name", InternalCommandStrings.PropertyOrMethodNotFound,
"PropertyOrMethodNotFound", _inputObject,
_propertyOrMethodName);
}
}
else
{
// member is [presumably] a property (note that it could be a
// dynamic property, if it shows up in GetDynamicMemberNames())
resolvedPropertyName = member.Name;
}
if (!String.IsNullOrEmpty(resolvedPropertyName))
{
// should process
string propertyAction = String.Format(CultureInfo.InvariantCulture,
InternalCommandStrings.ForEachObjectPropertyAction, resolvedPropertyName);
if (ShouldProcess(_targetString, propertyAction))
{
try
{
WriteToPipelineWithUnrolling(member.Value);
WriteToPipelineWithUnrolling(_propGetter.GetValue(InputObject, resolvedPropertyName));
}
catch (TerminateException) // The debugger is terminating the execution
{
@@ -439,17 +501,54 @@ namespace Microsoft.PowerShell.Commands
// PipelineStoppedException can be caused by select-object
throw;
}
catch (Exception)
catch (Exception ex)
{
// When the property is not gettable or it throws an exception.
// e.g. when trying to access an assembly's location property, since dynamic assemblies are not backed up by a file,
// an exception will be thrown when accessing its location property. In this case, return null.
WriteObject(null);
// For normal property accesses, we do not generate an error
// here. The problem for truly blind dynamic accesses (the
// member did not show up in GetDynamicMemberNames) is that
// we can't tell the difference between "it failed because
// the property does not exist" (let's call this case 1) and
// "it failed because accessing it actually threw some
// exception" (let's call that case 2).
//
// PowerShell behavior for normal (non-dynamic) properties
// is different for these two cases: case 1 gets an error
// (which is possible because the ETS tells us up front if
// the property exists or not), and case 2 does not. (For
// normal properties, this catch block /is/ case 2.)
//
// For IDMOPs, we have the chance to attempt a "blind"
// access, but the cost is that we must have the same
// response to both cases (because we cannot distinguish
// between the two). So we have to make a choice: we can
// either swallow ALL errors (including "The property
// 'Blarg' does not exist"), or expose them all.
//
// Here, for truly blind dynamic access, we choose to
// preserve the behavior of showing "The property 'Blarg'
// does not exist" (case 1) errors than to suppress
// "FooException thrown when accessing Bloop property" (case
// 2) errors.
if (isBlindDynamicAccess)
{
errorRecord = new ErrorRecord(ex,
"DynamicPropertyAccessFailed_" + _propertyOrMethodName,
ErrorCategory.InvalidOperation,
InputObject);
}
else
{
// When the property is not gettable or it throws an exception.
// e.g. when trying to access an assembly's location property, since dynamic assemblies are not backed up by a file,
// an exception will be thrown when accessing its location property. In this case, return null.
WriteObject(null);
}
}
}
} // end of member is a property
} // member is not null
} // no args provided
}
}
}
if (errorRecord != null)
{
@@ -1452,6 +1551,8 @@ namespace Microsoft.PowerShell.Commands
}
}
private DynamicPropertyGetter _propGetter;
/// <summary>
/// Execute the script block passing in the current pipeline object as
/// it's only parameter.
@@ -1579,6 +1680,9 @@ namespace Microsoft.PowerShell.Commands
// has keys that can't be compared to property.
}
string resolvedPropertyName = null;
bool isBlindDynamicAccess = false;
ReadOnlyPSMemberInfoCollection<PSMemberInfo> members = GetMatchMembers();
if (members.Count > 1)
{
@@ -1598,7 +1702,21 @@ namespace Microsoft.PowerShell.Commands
}
else if (members.Count == 0)
{
if (Context.IsStrictVersion(2))
if ((InputObject.BaseObject is IDynamicMetaObjectProvider) &&
!WildcardPattern.ContainsWildcardCharacters(_property))
{
// Let's just try a dynamic property access. Note that if it comes to
// depending on dynamic access, we are assuming it is a property; we
// don't have ETS info to tell us up front if it even exists or not,
// let alone if it is a method or something else.
//
// Note that this is "truly blind"--the name did not show up in
// GetDynamicMemberNames(), else it would show up as a dynamic member.
resolvedPropertyName = _property;
isBlindDynamicAccess = true;
}
else if (Context.IsStrictVersion(2))
{
WriteError(ForEachObjectCommand.GenerateNameParameterError("Property",
InternalCommandStrings.PropertyNotFound,
@@ -1607,10 +1725,15 @@ namespace Microsoft.PowerShell.Commands
}
}
else
{
resolvedPropertyName = members[0].Name;
}
if (!String.IsNullOrEmpty(resolvedPropertyName))
{
try
{
return members[0].Value;
return _propGetter.GetValue(_inputObject, resolvedPropertyName);
}
catch (TerminateException)
{
@@ -1620,10 +1743,45 @@ namespace Microsoft.PowerShell.Commands
{
throw;
}
catch (Exception)
catch (Exception ex)
{
// When the property is not gettable or it throws an exception
return null;
// For normal property accesses, we do not generate an error here. The problem
// for truly blind dynamic accesses (the member did not show up in
// GetDynamicMemberNames) is that we can't tell the difference between "it
// failed because the property does not exist" (let's call this case
// 1) and "it failed because accessing it actually threw some exception" (let's
// call that case 2).
//
// PowerShell behavior for normal (non-dynamic) properties is different for
// these two cases: case 1 gets an error (if strict mode is on) (which is
// possible because the ETS tells us up front if the property exists or not),
// and case 2 does not. (For normal properties, this catch block /is/ case 2.)
//
// For IDMOPs, we have the chance to attempt a "blind" access, but the cost is
// that we must have the same response to both cases (because we cannot
// distinguish between the two). So we have to make a choice: we can either
// swallow ALL errors (including "The property 'Blarg' does not exist"), or
// expose them all.
//
// Here, for truly blind dynamic access, we choose to preserve the behavior of
// showing "The property 'Blarg' does not exist" (case 1) errors than to
// suppress "FooException thrown when accessing Bloop property" (case
// 2) errors.
if (isBlindDynamicAccess && Context.IsStrictVersion(2))
{
WriteError(new ErrorRecord(ex,
"DynamicPropertyAccessFailed_" + _property,
ErrorCategory.InvalidOperation,
_inputObject));
error = true;
}
else
{
// When the property is not gettable or it throws an exception
return null;
}
}
}
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
Add-TestDynamicType
Describe "Where-Object" -Tags "CI" {
BeforeAll {
@@ -125,4 +126,20 @@ Describe "Where-Object" -Tags "CI" {
$Result = $Computers | Where-Object ComputerName -match '^MGC.+'
$Result.Count | Should -Be 1
}
It 'Where-Object should handle dynamic (DLR) objects' {
$dynObj = [TestDynamic]::new()
$Result = $dynObj, $dynObj | Where FooProp -eq 123
$Result.Count | Should -Be 2
$Result[0] | Should -Be $dynObj
$Result[1] | Should -Be $dynObj
}
It 'Where-Object should handle dynamic (DLR) objects, even without property name hint' {
$dynObj = [TestDynamic]::new()
$Result = $dynObj, $dynObj | Where HiddenProp -eq 789
$Result.Count | Should -Be 2
$Result[0] | Should -Be $dynObj
$Result[1] | Should -Be $dynObj
}
}
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
. (Join-Path -Path $PSScriptRoot -ChildPath Test-Mocks.ps1)
Add-TestDynamicType
Describe "Select-Object" -Tags "CI" {
BeforeEach {
@@ -242,6 +243,47 @@ Describe "Select-Object DRT basic functionality" -Tags "CI" {
$results.Count | Should -Be 1
$results[0] | Should -BeExactly "3"
}
It "Select-Object should handle dynamic (DLR) properties"{
$dynObj = [TestDynamic]::new()
$results = $dynObj, $dynObj | Select-Object -ExpandProperty FooProp
$results.Count | Should -Be 2
$results[0] | Should -Be 123
$results[1] | Should -Be 123
}
It "Select-Object should handle dynamic (DLR) properties without GetDynamicMemberNames hint"{
$dynObj = [TestDynamic]::new()
$results = $dynObj, $dynObj | Select-Object -ExpandProperty HiddenProp
$results.Count | Should -Be 2
$results[0] | Should -Be 789
$results[1] | Should -Be 789
}
It "Select-Object should handle wildcarded dynamic (DLR) properties when hinted by GetDynamicMemberNames"{
$dynObj = [TestDynamic]::new()
$results = $dynObj, $dynObj | Select-Object -ExpandProperty FooP*
$results.Count | Should -Be 2
$results[0] | Should -Be 123
$results[1] | Should -Be 123
}
It "Select-Object should work when multiple dynamic (DLR) properties match"{
$dynObj = [TestDynamic]::new()
$results = $dynObj, $dynObj | Select-Object *Prop
$results.Count | Should -Be 2
$results[0].FooProp | Should -Be 123
$results[0].BarProp | Should -Be 456
$results[1].FooProp | Should -Be 123
$results[1].BarProp | Should -Be 456
}
It "Select-Object -ExpandProperty should yield errors if multiple dynamic (DLR) properties match"{
$dynObj = [TestDynamic]::new()
$e = { $results = $dynObj, $dynObj | Select-Object -ExpandProperty *Prop -ErrorAction Stop} |
Should -Throw -PassThru -ErrorId "MutlipleExpandProperties,Microsoft.PowerShell.Commands.SelectObjectCommand"
$e.CategoryInfo | Should -Match "PSArgumentException"
}
}
Describe "Select-Object with Property = '*'" -Tags "CI" {
+15 -2
View File
@@ -162,7 +162,7 @@ Describe "Adapter Tests" -tags "CI" {
It "Common ForEach magic method tests" -Pending:$true {
}
It "ForEach magic method works for singletions" {
It "ForEach magic method works for singletons" {
$x = 5
$x.ForEach({$_}) | Should -Be 5
(5).ForEach({$_}) | Should -Be 5
@@ -187,13 +187,26 @@ Describe "Adapter Tests" -tags "CI" {
} -PassThru -Force
$x.ForEach(5) | Should -Be 10
}
# Pending: https://github.com/PowerShell/PowerShell/issues/6567
It "ForEach magic method works for dynamic (DLR) things" -Pending:$true {
Add-TestDynamicType
$dynObj = [TestDynamic]::new()
$results = @($dynObj, $dynObj).ForEach('FooProp')
$results.Count | Should -Be 2
$results[0] | Should -Be 123
$results[1] | Should -Be 123
# TODO: dynamic method calls
}
}
Context "Where Magic Method Adapter Tests" {
It "Common Where magic method tests" -Pending:$true {
}
It "Where magic method works for singletions" {
It "Where magic method works for singletons" {
$x = 5
$x.Where({$true}) | Should -Be 5
(5).Where({$true}) | Should -Be 5
@@ -16,5 +16,5 @@ Copyright = 'Copyright (c) Microsoft Corporation. All rights reserved.'
Description = 'Temporary module contains functions for using in tests'
FunctionsToExport = 'Wait-UntilTrue', 'Test-IsElevated', 'Wait-FileToBePresent', 'Get-RandomFileName', 'Enable-Testhook', 'Disable-Testhook', 'Set-TesthookResult', 'Test-TesthookIsSet'
FunctionsToExport = 'Wait-UntilTrue', 'Test-IsElevated', 'Wait-FileToBePresent', 'Get-RandomFileName', 'Enable-Testhook', 'Disable-Testhook', 'Set-TesthookResult', 'Test-TesthookIsSet', 'Add-TestDynamicType'
}
@@ -115,3 +115,80 @@ function Set-TesthookResult
)
${Script:TesthookType}::SetTestHook($testhookName, $value)
}
function Add-TestDynamicType
{
param()
Add-Type -TypeDefinition @'
using System.Collections.Generic;
using System.Dynamic;
public class TestDynamic : DynamicObject
{
private static readonly string[] s_dynamicMemberNames = new string[] { "FooProp", "BarProp", "FooMethod", "SerialNumber" };
private static int s_lastSerialNumber;
private readonly int _serialNumber;
public TestDynamic()
{
_serialNumber = ++s_lastSerialNumber;
}
public override IEnumerable<string> GetDynamicMemberNames()
{
return s_dynamicMemberNames;
}
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
result = null;
if (binder.Name == "FooProp")
{
result = 123;
return true;
}
else if (binder.Name == "BarProp")
{
result = 456;
return true;
}
else if (binder.Name == "SerialNumber")
{
result = _serialNumber;
return true;
}
else if (binder.Name == "HiddenProp")
{
// Not presented in GetDynamicMemberNames
result = 789;
return true;
}
return false;
}
public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
{
result = null;
if (binder.Name == "FooMethod")
{
result = "yes";
return true;
}
else if (binder.Name == "HiddenMethod")
{
// Not presented in GetDynamicMemberNames
result = _serialNumber;
return true;
}
return false;
}
}
'@
}