diff --git a/src/System.Management.Automation/FormatAndOutput/common/Utilities/Mshexpression.cs b/src/System.Management.Automation/FormatAndOutput/common/Utilities/Mshexpression.cs index 41976170a4b..d8851b2daed 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/Utilities/Mshexpression.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/Utilities/Mshexpression.cs @@ -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 @@ internal List ResolveNames(PSObject target, bool expand) { // 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 temp = new List(); if (x != null) { @@ -165,10 +174,14 @@ internal List ResolveNames(PSObject target, bool expand) } } } - 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 @@ internal List GetValues(PSObject target, bool expand, bool #region Private Members + private CallSite> _getValueDynamicSite; + private MshExpressionResult GetValue(PSObject target, bool eatExceptions) { try { - object result; + object result = null; if (Script != null) { @@ -243,12 +258,16 @@ private MshExpressionResult GetValue(PSObject target, bool eatExceptions) } else { - PSMemberInfo member = target.Properties[_stringValue]; - if (member == null) + if (_getValueDynamicSite == null) { - return new MshExpressionResult(null, this, null); + _getValueDynamicSite = + CallSite>.Create( + PSGetMemberBinder.Get( + _stringValue, + classScope: (Type) null, + @static: false)); } - result = member.Value; + result = _getValueDynamicSite.Target.Invoke(_getValueDynamicSite, target); } return new MshExpressionResult(result, this, null); diff --git a/src/System.Management.Automation/engine/InternalCommands.cs b/src/System.Management.Automation/engine/InternalCommands.cs index ea04bee4091..2cd29f9b426 100644 --- a/src/System.Management.Automation/engine/InternalCommands.cs +++ b/src/System.Management.Automation/engine/InternalCommands.cs @@ -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 @@ namespace Microsoft.PowerShell.Commands { + /// + /// A thin wrapper over a property-getting Callsite, to allow reuse when possible. + /// + struct DynamicPropertyGetter + { + private CallSite> _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>.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. /// @@ -136,6 +170,7 @@ public string MemberName } private string _propertyOrMethodName; private string _targetString; + private DynamicPropertyGetter _propGetter; /// /// The arguments passed to a method invocation @@ -351,80 +386,107 @@ protected override void ProcessRecord() 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) { - // 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); + // 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; + // 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); + 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 (ShouldProcess(_targetString, methodAction)) + { + if (!BlockMethodInLanguageMode(InputObject)) { - if (!BlockMethodInLanguageMode(InputObject)) - { - object result = targetMethod.Invoke(Utils.EmptyArray()); - WriteToPipelineWithUnrolling(result); - } + object result = targetMethod.Invoke(Utils.EmptyArray()); + WriteToPipelineWithUnrolling(result); } } - catch (PipelineStoppedException) + } + 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") { - // PipelineStoppedException can be caused by select-object - throw; + WriteObject(targetMethod.Value); } - catch (Exception ex) + else { - 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)); - } + 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); } } - // member is a property 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, member.Name); + 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 @@ protected override void ProcessRecord() // 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 @@ protected override void BeginProcessing() } } + private DynamicPropertyGetter _propGetter; + /// /// Execute the script block passing in the current pipeline object as /// it's only parameter. @@ -1579,6 +1680,9 @@ private object GetValue(ref bool error) // has keys that can't be compared to property. } + string resolvedPropertyName = null; + bool isBlindDynamicAccess = false; + ReadOnlyPSMemberInfoCollection members = GetMatchMembers(); if (members.Count > 1) { @@ -1598,7 +1702,21 @@ private object GetValue(ref bool error) } 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 @@ private object GetValue(ref bool error) } } 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 @@ private object GetValue(ref bool error) { 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; + } } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Core/Where-Object.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Core/Where-Object.Tests.ps1 index 18e53c2e74a..0b3f4f7d764 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Core/Where-Object.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Core/Where-Object.Tests.ps1 @@ -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 + } } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Select-Object.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Select-Object.Tests.ps1 index c799101ed8e..8565b3ccaef 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Select-Object.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Select-Object.Tests.ps1 @@ -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" { diff --git a/test/powershell/engine/ETS/Adapter.Tests.ps1 b/test/powershell/engine/ETS/Adapter.Tests.ps1 index ac1c23c0c81..768dbdc60f3 100644 --- a/test/powershell/engine/ETS/Adapter.Tests.ps1 +++ b/test/powershell/engine/ETS/Adapter.Tests.ps1 @@ -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 diff --git a/test/tools/Modules/HelpersCommon/HelpersCommon.psd1 b/test/tools/Modules/HelpersCommon/HelpersCommon.psd1 index d8ef7f01e69..f811668d6ae 100644 --- a/test/tools/Modules/HelpersCommon/HelpersCommon.psd1 +++ b/test/tools/Modules/HelpersCommon/HelpersCommon.psd1 @@ -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' } diff --git a/test/tools/Modules/HelpersCommon/HelpersCommon.psm1 b/test/tools/Modules/HelpersCommon/HelpersCommon.psm1 index afae5622452..659f28e8e00 100644 --- a/test/tools/Modules/HelpersCommon/HelpersCommon.psm1 +++ b/test/tools/Modules/HelpersCommon/HelpersCommon.psm1 @@ -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 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; + } +} +'@ +}