From d932f8c46e5adcc7b39969c4c719f0c131b6696a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Fri, 11 Jul 2025 17:59:49 +0200 Subject: [PATCH 01/14] Compiled lambda expression, which is faster than the previous GetValue reflection approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Columns/TableViewBoundColumn.cs | 22 ++-- src/Extensions/ObjectExtensions.cs | 184 +++++++++++----------------- 2 files changed, 84 insertions(+), 122 deletions(-) diff --git a/src/Columns/TableViewBoundColumn.cs b/src/Columns/TableViewBoundColumn.cs index 273ba56..afa29f4 100644 --- a/src/Columns/TableViewBoundColumn.cs +++ b/src/Columns/TableViewBoundColumn.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; using System; +using System.Linq.Expressions; using System.Reflection; using WinUI.TableView.Extensions; @@ -11,24 +12,21 @@ namespace WinUI.TableView; /// public abstract class TableViewBoundColumn : TableViewColumn { - private Type? _listType; private string? _propertyPath; private Binding _binding = new(); - private (PropertyInfo, object?)[]? _propertyInfo; + + private Func? _funcCompiledPropertyPath; public override object? GetCellContent(object? dataItem) { - if (dataItem is null) return null; + if (dataItem is null) + return null; - if (_propertyInfo is null || dataItem.GetType() != _listType) - { - _listType = dataItem.GetType(); - dataItem = dataItem.GetValue(_listType, PropertyPath, out _propertyInfo); - } - else - { - dataItem = dataItem.GetValue(_propertyInfo); - } + if (_funcCompiledPropertyPath is null && !string.IsNullOrWhiteSpace(PropertyPath)) + _funcCompiledPropertyPath = dataItem.GetFuncCompiledPropertyPath(PropertyPath!); + + if (_funcCompiledPropertyPath is not null) + dataItem = _funcCompiledPropertyPath(dataItem); if (Binding?.Converter is not null) { diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index a47db75..d423e32 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -1,6 +1,8 @@ +using Microsoft.UI.Xaml; using System; using System.Collections; using System.Linq; +using System.Linq.Expressions; using System.Reflection; using System.Text.RegularExpressions; @@ -16,146 +18,108 @@ internal static partial class ObjectExtensions private static partial Regex PropertyPathRegex(); /// - /// Gets the value of a property from an object using a sequence of property info and index pairs. + /// Creates and returns a compiled lambda expression for accessing the property path on instances, with runtime type checking and casting support. /// - /// The object from which to get the value. - /// An array of property info and index pairs. - /// The value of the property, or null if the object is null. - internal static object? GetValue(this object? obj, (PropertyInfo pi, object? index)[] pis) + /// The data item instance to use for runtime type evaluation. + /// The binding path to access, e.g. "[0].SubPropertyArray[0].SubSubProperty". + /// A compiled function that takes an instance and returns the property value, or null if the property path is invalid. + public static Func? GetFuncCompiledPropertyPath(this object dataItem, string bindingPath) { - foreach (var (pi, index) in pis) + try { - if (obj is null) - break; - - if (pi != null) - { - // Use property getter, with or without index - obj = index is not null ? pi.GetValue(obj, [index]) : pi.GetValue(obj); - } - else if (index is int i) - { - // Array - if (obj is Array arr) - { - obj = arr.GetValue(i); - } - // IList - else if (obj is IList list) - { - obj = list[i]; - } - else - { - // Not a supported indexer type - return null; - } - } - else - { - // Not a supported path segment - return null; - } + // Build the property access expression chain with runtime type checking + var parameterObj = Expression.Parameter(typeof(object), "obj"); + var propertyAccess = BuildPropertyPathExpressionTree(parameterObj, bindingPath, dataItem); + + // Compile the lambda expression + var lambda = Expression.Lambda>(Expression.Convert(propertyAccess, typeof(object)), parameterObj); + var _compiledPropertyPath = lambda.Compile(); + return _compiledPropertyPath; + } + catch + { + // If compilation fails, fall back to reflection-based approach + return null; } - - return obj; } /// - /// Gets the value of a property from an object using a type and a property path. + /// Builds an expression tree for accessing a property path on the given instance expression, with runtime type checking and casting support. /// - /// The object from which to get the value. - /// The type of the object. - /// The property path. - /// An array of property info and index pairs. - /// The value of the property, or null if the object is null. - internal static object? GetValue(this object? obj, Type? type, string? path, out (PropertyInfo pi, object? index)[] pis) + /// The expression representing the instance parameter for which the binding path will be evaluated. + /// The binding path to access. + /// The actual data item to use for runtime type evaluation, to help with any needed subclass type conversions. + /// An expression that accesses the binding path from the + private static Expression BuildPropertyPathExpressionTree(ParameterExpression parameterObj, string bindingPath, object dataItem) { - if (obj == null || string.IsNullOrWhiteSpace(path) || type == null) - { - pis = []; - return obj; - } + Expression current = parameterObj; - var matches = PropertyPathRegex().Matches(path); - if (matches.Count == 0) - { - pis = []; - return obj; - } + // The function uses a generic object input parameter to allow for any type of data item, + // but we need to ensure that the runtime type matches the data item type that is inputted as example to be able to find members + if (current.Type != dataItem.GetType()) + current = Expression.Convert(current, dataItem.GetType()); - // Pre-size the steps array to the number of matches - pis = new (PropertyInfo, object?)[matches.Count]; - int i = 0; - object? current = obj; - Type? currentType = type; + var matches = PropertyPathRegex().Matches(bindingPath); foreach (Match match in matches) { string part = match.Value; - object? index = null; - PropertyInfo? pi = null; + // Indexer if (part.StartsWith('[') && part.EndsWith(']')) { - // Indexer: [int] or [string] - string indexer = part[1..^1]; - if (int.TryParse(indexer, out int intIndex)) - index = intIndex; - else - index = indexer; - - // Try array - if (current is Array arr && index is int idx) - { - current = arr.GetValue(idx); - pis[i++] = (null!, idx); - currentType = current?.GetType(); - continue; - } + string stringIndex = part[1..^1]; - // Try IList - if (current is IList list && index is int idx2) + // See if this is an integer index + if (int.TryParse(stringIndex, out int index)) { - current = list[idx2]; - pis[i++] = (null!, idx2); - currentType = current?.GetType(); - continue; + if (current.Type.IsArray) + { + current = Expression.ArrayIndex(current, Expression.Constant(index)); + } + else + { + // Try to find an indexer property, with an int parameter + var indexerProperty = current.Type.GetProperty("Item", [typeof(int)]) + ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support integer indexing"); + current = Expression.Property(current, indexerProperty, Expression.Constant(index)); + } } - - // Try to find a default indexer property "Item" (e.g., this[string]); - // Note that only single argument indexers of type int or string are currently support - pi = currentType?.GetProperty("Item", [index.GetType()]); - if (pi != null) + else { - current = pi.GetValue(current, [index]); - pis[i++] = (pi, index); - currentType = current?.GetType(); - continue; + // Try to find an indexer property, with an string parameter + var indexerProperty = current.Type.GetProperty("Item", [typeof(string)]) + ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support string indexing"); + current = Expression.Property(current, indexerProperty, Expression.Constant(stringIndex)); } - - // Not found - pis = null!; - return null; } + // Simple property access else - { - // Property access - pi = currentType?.GetProperty(part, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (pi == null) - { - pis = null!; - return null; - } - current = pi.GetValue(current); - pis[i++] = (pi, null); - currentType = current?.GetType(); + { + var propertyInfo = current.Type.GetProperty(part, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new ArgumentException($"Property '{part}' not found on type '{current.Type.Name}'"); + current = Expression.Property(current, propertyInfo); } + + // Compile a lambda of the partial expression thus far (cast to object), to see if we need to add a cast + var lambdaTemp = Expression.Lambda>(EnsureObjectCompatibleResult(current), parameterObj); + var funcCurrent = lambdaTemp.Compile(); + // Evaluate this compiled function, to see if the result type is more specific than the current expression type. If so, cast to it + var result = funcCurrent(dataItem); + var runtimeType = result?.GetType() ?? current.Type; + if (current.Type != runtimeType && current.Type.IsAssignableFrom(runtimeType)) + current = Expression.Convert(current, runtimeType); } - return current; + return EnsureObjectCompatibleResult(current); } + static Expression EnsureObjectCompatibleResult(Expression expression) => + typeof(object).IsAssignableFrom(expression.Type) && !expression.Type.IsValueType + ? expression + : Expression.Convert(expression, typeof(object)); + + /// /// Determines whether the specified object is numeric. /// From 323e9aa21637183945488219a39861feafa020d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Sat, 2 Aug 2025 15:33:33 +0200 Subject: [PATCH 02/14] Processed Copilot review comments on Github MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index d423e32..67829aa 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -23,7 +23,7 @@ internal static partial class ObjectExtensions /// The data item instance to use for runtime type evaluation. /// The binding path to access, e.g. "[0].SubPropertyArray[0].SubSubProperty". /// A compiled function that takes an instance and returns the property value, or null if the property path is invalid. - public static Func? GetFuncCompiledPropertyPath(this object dataItem, string bindingPath) + internal static Func? GetFuncCompiledPropertyPath(this object dataItem, string bindingPath) { try { @@ -49,7 +49,7 @@ internal static partial class ObjectExtensions /// The expression representing the instance parameter for which the binding path will be evaluated. /// The binding path to access. /// The actual data item to use for runtime type evaluation, to help with any needed subclass type conversions. - /// An expression that accesses the binding path from the + /// An expression that accesses the property value specified by the binding path for the provided dataItem instance. private static Expression BuildPropertyPathExpressionTree(ParameterExpression parameterObj, string bindingPath, object dataItem) { Expression current = parameterObj; @@ -114,7 +114,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa return EnsureObjectCompatibleResult(current); } - static Expression EnsureObjectCompatibleResult(Expression expression) => + private static Expression EnsureObjectCompatibleResult(Expression expression) => typeof(object).IsAssignableFrom(expression.Type) && !expression.Type.IsValueType ? expression : Expression.Convert(expression, typeof(object)); From 067465bc89784f93083aee1f4f1ce4565204495a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Tue, 5 Aug 2025 09:00:33 +0200 Subject: [PATCH 03/14] Updated unit test previous GetValue() reflection approach, with the new compiled function approach. Note: the support for Dictionary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- tests/ObjectExtensionsTests.cs | 130 +++++++-------------------------- 1 file changed, 28 insertions(+), 102 deletions(-) diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index e549279..9d1522e 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections; using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; using WinUI.TableView.Extensions; @@ -10,147 +8,75 @@ namespace WinUI.TableView.Tests; public class ObjectExtensionsTests { [TestMethod] - public void GetValue_ShouldAccessSimpleNestedProperty_UsingPathString() + public void GetFuncCompiledPropertyPath_ShouldAccessSimpleNestedProperty() { var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - var result = testItem.GetValue(typeof(TestItem), "SubItems[0].SubSubItems[0].Name", out var _); - Assert.IsNotNull(result); + var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Name"); + Assert.IsNotNull(func); + var result = func(testItem); Assert.AreEqual("NestedValue", result); } [TestMethod] - public void GetValue_ShouldAccessSimpleNestedProperty_UsingParsedPath() - { - var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - testItem.GetValue(typeof(TestItem), "SubItems[0].SubSubItems[0].Name", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNotNull(result); - Assert.AreEqual("NestedValue", result); - } - - [TestMethod] - public void GetValue_ShouldAccessArrayElement_UsingPathString() - { - var testItem = new TestItem { IntArray = [10, 20, 30] }; - var result = testItem.GetValue(typeof(TestItem), "IntArray[1]", out var _); - Assert.IsNotNull(result); - Assert.AreEqual(20, result); - } - - [TestMethod] - public void GetValue_ShouldAccessArrayElement_UsingParsedPath() + public void GetFuncCompiledPropertyPath_ShouldAccessArrayElement() { var testItem = new TestItem { IntArray = [10, 20, 30] }; - testItem.GetValue(typeof(TestItem), "IntArray[1]", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNotNull(result); + var func = testItem.GetFuncCompiledPropertyPath("IntArray[1]"); + Assert.IsNotNull(func); + var result = func(testItem); Assert.AreEqual(20, result); } [TestMethod] - public void GetValue_ShouldAccessDictionaryByStringKey_UsingPathString() + public void GetFuncCompiledPropertyPath_ShouldAccessDictionaryByStringKey() { var testItem = new TestItem { Dictionary1 = new() { { "key1", "value1" } } }; - var result = testItem.GetValue(typeof(TestItem), "Dictionary1[key1]", out var _); - Assert.IsNotNull(result); - Assert.AreEqual("value1", result); - } - - [TestMethod] - public void GetValue_ShouldAccessDictionaryByStringKey_UsingParsedPath() - { - var testItem = new TestItem { Dictionary1 = new() { { "key1", "value1" } } }; - testItem.GetValue(typeof(TestItem), "Dictionary1[key1]", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNotNull(result); - Assert.AreEqual("value1", result); - } - - [TestMethod] - public void GetValue_ShouldAccessDictionaryByIntKey_UsingPathString() - { - var testItem = new TestItem { Dictionary2 = new() { { 1, "value1" } } }; - var result = testItem.GetValue(typeof(TestItem), "Dictionary2[1]", out var _); - Assert.IsNotNull(result); + var func = testItem.GetFuncCompiledPropertyPath("Dictionary1[key1]"); + Assert.IsNotNull(func); + var result = func(testItem); Assert.AreEqual("value1", result); } [TestMethod] - public void GetValue_ShouldAccessDictionaryByIntKey_UsingParsedPath() + public void GetFuncCompiledPropertyPath_ShouldAccessDictionaryByIntKey() { var testItem = new TestItem { Dictionary2 = new() { { 1, "value1" } } }; - testItem.GetValue(typeof(TestItem), "Dictionary2[1]", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNotNull(result); + var func = testItem.GetFuncCompiledPropertyPath("Dictionary2[1]"); + Assert.IsNotNull(func); + var result = func(testItem); Assert.AreEqual("value1", result); } [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidProperty_UsingPathString() - { - var testItem = new TestItem(); - var result = testItem.GetValue(typeof(TestItem), "NonExistent.Property.Path", out var _); - Assert.IsNull(result); - } - - [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidProperty_UsingParsedPath() - { - var testItem = new TestItem(); - testItem.GetValue(typeof(TestItem), "NonExistent.Property.Path", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNull(result); - } - - [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidProperty2_UsingPathString() + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty() { var testItem = new TestItem(); - var result = testItem.GetValue(typeof(TestItem), "SubItems[0].SubSubItems[0].Invalid", out var _); - Assert.IsNull(result); + var func = testItem.GetFuncCompiledPropertyPath("NonExistent.Property.Path"); + Assert.IsNull(func); } [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidProperty2_UsingParsedPath() + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty2() { var testItem = new TestItem(); - testItem.GetValue(typeof(TestItem), "SubItems[0].SubSubItems[0].Invalid", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNull(result); - } - - [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidProperty3_UsingPathString() - { - var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - var result = testItem.GetValue(typeof(TestItem), "SubItems[0].SubSubItems[0].Invalid", out var _); - Assert.IsNull(result); + var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Invalid"); + Assert.IsNull(func); } [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidProperty3_UsingParsedPath() + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty3() { var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - testItem.GetValue(typeof(TestItem), "SubItems[0].SubSubItems[0].Invalid", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNull(result); - } - - [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidIndexer_UsingPathString() - { - var testItem = new TestItem(); - var result = testItem.GetValue(typeof(TestItem), "Dictionary[123]", out var _); - Assert.IsNull(result); + var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Invalid"); + Assert.IsNull(func); } [TestMethod] - public void GetValue_ShouldReturnNull_ForInvalidIndexer_UsingParsedPath() + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidIndexer() { var testItem = new TestItem(); - testItem.GetValue(typeof(TestItem), "Dictionary[123]", out var pis); - var result = testItem.GetValue(pis); - Assert.IsNull(result); + var func = testItem.GetFuncCompiledPropertyPath("Dictionary[123]"); + Assert.IsNull(func); } [TestMethod] From 34cc106c62b018d6521b1e743542b8ff733ac3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Thu, 7 Aug 2025 11:14:11 +0200 Subject: [PATCH 04/14] Simplification of the indexer code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index c5848ff..6ece4ec 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -31,6 +31,8 @@ internal static partial class ObjectExtensions var parameterObj = Expression.Parameter(typeof(object), "obj"); var propertyAccess = BuildPropertyPathExpressionTree(parameterObj, bindingPath, dataItem); + // var strCode = propertyAccess.ToString(); // Uncomment to debug the expression tree + // Compile the lambda expression var lambda = Expression.Lambda>(Expression.Convert(propertyAccess, typeof(object)), parameterObj); var _compiledPropertyPath = lambda.Compile(); @@ -38,7 +40,6 @@ internal static partial class ObjectExtensions } catch { - // If compilation fails, fall back to reflection-based approach return null; } } @@ -69,28 +70,18 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa if (part.StartsWith('[') && part.EndsWith(']')) { string stringIndex = part[1..^1]; + object index = int.TryParse(stringIndex, out int intIndex) ? intIndex : stringIndex; - // See if this is an integer index - if (int.TryParse(stringIndex, out int index)) + if (current.Type.IsArray) { - if (current.Type.IsArray) - { - current = Expression.ArrayIndex(current, Expression.Constant(index)); - } - else - { - // Try to find an indexer property, with an int parameter - var indexerProperty = current.Type.GetProperty("Item", [typeof(int)]) - ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support integer indexing"); - current = Expression.Property(current, indexerProperty, Expression.Constant(index)); - } + current = Expression.ArrayIndex(current, Expression.Constant(index)); } else { - // Try to find an indexer property, with an string parameter - var indexerProperty = current.Type.GetProperty("Item", [typeof(string)]) - ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support string indexing"); - current = Expression.Property(current, indexerProperty, Expression.Constant(stringIndex)); + // Try to find an indexer property, with the type of by indexAnyType. + var indexerProperty = current.Type.GetProperty("Item", [index.GetType()]) + ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support integer indexing"); + current = Expression.Property(current, indexerProperty, Expression.Constant(index)); } } // Simple property access From 99d2f628316fca804fbfa3ef1af69ccd956caa51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Thu, 7 Aug 2025 11:20:08 +0200 Subject: [PATCH 05/14] Generic support for multi-dimensional indexers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 38 ++++++++++++++++++++++++------ tests/ObjectExtensionsTests.cs | 30 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 6ece4ec..d0e6c53 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -69,19 +69,27 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa // Indexer if (part.StartsWith('[') && part.EndsWith(']')) { - string stringIndex = part[1..^1]; - object index = int.TryParse(stringIndex, out int intIndex) ? intIndex : stringIndex; + object[] indices = GetIndices(part[1..^1]); if (current.Type.IsArray) { - current = Expression.ArrayIndex(current, Expression.Constant(index)); + // Arrays only support single integer indexing + if (!indices.All(idx => idx is int)) + throw new ArgumentException($"Arrays only support integer indexing, not the provided indexer [{part[1..^1]}]"); + + var indexExpressions = indices.Select(Expression.Constant).ToArray(); + current = Expression.ArrayIndex(current, indexExpressions); } else { - // Try to find an indexer property, with the type of by indexAnyType. - var indexerProperty = current.Type.GetProperty("Item", [index.GetType()]) - ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support integer indexing"); - current = Expression.Property(current, indexerProperty, Expression.Constant(index)); + // Try to find an indexer property with the appropriate parameter types + var indexerTypes = indices.Select(idx => idx.GetType()).ToArray(); + var indexerProperty = current.Type.GetProperty("Item", indexerTypes) + ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support indexing with types: {string.Join(", ", indexerTypes.Select(t => t.Name))}"); + + // Create constant expressions for each index + var indexExpressions = indices.Select(Expression.Constant).ToArray(); + current = Expression.Property(current, indexerProperty, indexExpressions); } } // Simple property access @@ -105,6 +113,22 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa return EnsureObjectCompatibleResult(current); } + /// + /// Returns the indices from a (possible multi-dimensional) indexer that may be a mixture of integers and strings. + /// + private static object[] GetIndices(string stringIndexer) + { + // Split by comma and parse each indexer, removing whitespace + var indexerParts = stringIndexer.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .ToArray(); + + // Parse each part into appropriate type (int or string) + return [.. indexerParts.Select(indexPart => + int.TryParse(indexPart, out int intIndex) ? (object)intIndex : indexPart + )]; + } + private static Expression EnsureObjectCompatibleResult(Expression expression) => typeof(object).IsAssignableFrom(expression.Type) && !expression.Type.IsValueType ? expression diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index 9d1522e..a82ee7b 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -27,6 +27,27 @@ public void GetFuncCompiledPropertyPath_ShouldAccessArrayElement() Assert.AreEqual(20, result); } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccess2DArrayElement() + { + var testItem = new TestItem { Int2DArray = new int[,] {{1, 2, 3}, {10, 20, 30}} }; + var func = testItem.GetFuncCompiledPropertyPath("Int2DArray[1,1]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual(20, result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccessMultiDimensionalIndexer() + { + var testItem = new TestItem(); + testItem[2, "foo"] = "bar"; + var func = testItem.GetFuncCompiledPropertyPath("[2,foo]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual("bar", result); + } + [TestMethod] public void GetFuncCompiledPropertyPath_ShouldAccessDictionaryByStringKey() { @@ -143,5 +164,14 @@ private class TestItem public Dictionary Dictionary1 { get; set; } = []; public Dictionary Dictionary2 { get; set; } = []; public int[] IntArray { get; set; } = []; + public int[,] Int2DArray { get; set; } = new int[0, 0]; + + // Multi-dimensional indexer + private readonly Dictionary<(int, string), string> _multiIndex = new(); + public string this[int i, string key] + { + get => _multiIndex.TryGetValue((i, key), out var value) ? value : string.Empty; + set => _multiIndex[(i, key)] = value; + } } } From dc07aa71393d7b6770a68bbce439fc87415e12c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Thu, 7 Aug 2025 16:36:40 +0200 Subject: [PATCH 06/14] Added NULL checks for Arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 64 ++++++++++++++++++++++++++---- tests/ObjectExtensionsTests.cs | 24 +++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index d0e6c53..28da323 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -1,4 +1,3 @@ -using Microsoft.UI.Xaml; using System; using System.Collections; using System.Linq; @@ -31,10 +30,10 @@ internal static partial class ObjectExtensions var parameterObj = Expression.Parameter(typeof(object), "obj"); var propertyAccess = BuildPropertyPathExpressionTree(parameterObj, bindingPath, dataItem); - // var strCode = propertyAccess.ToString(); // Uncomment to debug the expression tree - // Compile the lambda expression var lambda = Expression.Lambda>(Expression.Convert(propertyAccess, typeof(object)), parameterObj); + + // To debug, set a breakpoint below and inspect the "DebugView" property on "propertyAccess" var _compiledPropertyPath = lambda.Compile(); return _compiledPropertyPath; } @@ -57,8 +56,11 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa // The function uses a generic object input parameter to allow for any type of data item, // but we need to ensure that the runtime type matches the data item type that is inputted as example to be able to find members - if (current.Type != dataItem.GetType()) - current = Expression.Convert(current, dataItem.GetType()); + { + var type = dataItem.GetType(); + if (current.Type != type && !type.IsValueType) + current = Expression.Convert(current, dataItem.GetType()); + } var matches = PropertyPathRegex().Matches(bindingPath); @@ -77,8 +79,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa if (!indices.All(idx => idx is int)) throw new ArgumentException($"Arrays only support integer indexing, not the provided indexer [{part[1..^1]}]"); - var indexExpressions = indices.Select(Expression.Constant).ToArray(); - current = Expression.ArrayIndex(current, indexExpressions); + current = AddArrayAccessWithBoundsCheck(current, [.. indices.Select(index => (int)index)]); } else { @@ -106,7 +107,10 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa // Evaluate this compiled function, to see if the result type is more specific than the current expression type. If so, cast to it var result = funcCurrent(dataItem); var runtimeType = result?.GetType() ?? current.Type; - if (current.Type != runtimeType && current.Type.IsAssignableFrom(runtimeType)) + if (current.Type != runtimeType + && current.Type.IsAssignableFrom(runtimeType) + && !runtimeType.IsValueType // Avoid conversion for value types; the result could be null, causing System.NullReferenceException at runtime + ) current = Expression.Convert(current, runtimeType); } @@ -129,6 +133,50 @@ private static object[] GetIndices(string stringIndexer) )]; } + /// + /// Adds array access to the current expression, given the inputted indices, and adds bounds checking; + /// the code for this expression will return null if the indices are out of bounds. + /// + private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[] indices) + { + if (!current.Type.IsArray) + throw new ArgumentException("Current expression must be an array."); + + var rank = current.Type.GetArrayRank(); + if (indices.Length != rank) + throw new ArgumentException($"Array of rank {rank} requires {rank} indices, but {indices.Length} were provided."); + + // Null check on the input array + var notNullCheck = Expression.NotEqual(current, Expression.Constant(null, current.Type)); + + // Bounds check for each dimension + Expression boundsCheck = null!; + var getLengthMethod = typeof(Array).GetMethod("GetLength")!; + var indexConstants = new Expression[rank]; + for (int dimension = 0; dimension < rank; dimension++) + { + var index = indices[dimension]; + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(indices), $"Index for dimension {dimension} cannot be negative: {index}"); + + var indexConst = Expression.Constant(index); + indexConstants[dimension] = indexConst; + + // GetLength method call for the current dimension + var lengthProp = Expression.Call(current, getLengthMethod, Expression.Constant(dimension)); + + var dimCheck = Expression.LessThan(indexConst, lengthProp); + boundsCheck = boundsCheck == null ? dimCheck : Expression.AndAlso(boundsCheck, dimCheck); + } + + // Return the conditional expression directly - this will cause the parent Func<> to return null + return Expression.Condition( + Expression.AndAlso(notNullCheck, boundsCheck), // null-check and bounds-check + Expression.Convert(Expression.ArrayAccess(current, indexConstants), typeof(object)), + Expression.Constant(null, typeof(object)) + ); + } + private static Expression EnsureObjectCompatibleResult(Expression expression) => typeof(object).IsAssignableFrom(expression.Type) && !expression.Type.IsValueType ? expression diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index a82ee7b..9b01222 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -100,6 +100,30 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidIndexer() Assert.IsNull(func); } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsArrayIndex() + { + var testItem = new TestItem { IntArray = [10, 20, 30] }; + var func = testItem.GetFuncCompiledPropertyPath("IntArray[2]"); + Assert.IsNotNull(func); + var testItem2 = new TestItem { IntArray = [1] }; + var result = func(testItem2); + Assert.IsNull(result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsMultiDimArrayIndex() + { + var testItem3x3 = new TestItem { Int2DArray = new int[,] { { 1, 2, 3 }, { 10, 20, 30 } } }; + var func = testItem3x3.GetFuncCompiledPropertyPath("Int2DArray[2,2]"); + Assert.IsNotNull(func); + var result = func(testItem3x3); + + var testItem2x2 = new TestItem { Int2DArray = new int[,] { { 1, 2 }, { 10, 30 } } }; + result = func(testItem2x2); + Assert.IsNull(result); + } + [TestMethod] public void GetItemType_ShouldReturnCorrectType_ForGenericEnumerable() { From 0f6a967a34aa10ba9b749039125f83a973fb179b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Thu, 7 Aug 2025 17:25:17 +0200 Subject: [PATCH 07/14] More performant approach that is more readable when reviewing the Expression/Lambda code with DebugView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel (cherry picked from commit 5c0ccd2b9be1866427b74c91d752f8fefa4b88b4) --- src/Extensions/ObjectExtensions.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 28da323..b4575ab 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -142,12 +142,17 @@ private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[ if (!current.Type.IsArray) throw new ArgumentException("Current expression must be an array."); + // Since array/indexer handling does various checks, it is more performant and readable to store the current expression in a variable + // which we will use as input to the block with array/indexer handling code. + var parameterArray = Expression.Parameter(current.Type, "array"); + var assignArray = Expression.Assign(parameterArray, current); + var rank = current.Type.GetArrayRank(); if (indices.Length != rank) throw new ArgumentException($"Array of rank {rank} requires {rank} indices, but {indices.Length} were provided."); // Null check on the input array - var notNullCheck = Expression.NotEqual(current, Expression.Constant(null, current.Type)); + var notNullCheck = Expression.NotEqual(parameterArray, Expression.Constant(null)); // Bounds check for each dimension Expression boundsCheck = null!; @@ -163,18 +168,26 @@ private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[ indexConstants[dimension] = indexConst; // GetLength method call for the current dimension - var lengthProp = Expression.Call(current, getLengthMethod, Expression.Constant(dimension)); + var lengthProp = Expression.Call(parameterArray, getLengthMethod, Expression.Constant(dimension)); var dimCheck = Expression.LessThan(indexConst, lengthProp); boundsCheck = boundsCheck == null ? dimCheck : Expression.AndAlso(boundsCheck, dimCheck); } // Return the conditional expression directly - this will cause the parent Func<> to return null - return Expression.Condition( + var expressionBlock = Expression.Condition( Expression.AndAlso(notNullCheck, boundsCheck), // null-check and bounds-check - Expression.Convert(Expression.ArrayAccess(current, indexConstants), typeof(object)), + Expression.Convert(Expression.ArrayAccess(parameterArray, indexConstants), typeof(object)), Expression.Constant(null, typeof(object)) ); + + // Add the block + return Expression.Block( + new[] { parameterArray }, + assignArray, + expressionBlock + ); + } private static Expression EnsureObjectCompatibleResult(Expression expression) => From 5343d4124a32447ac500dc1ee232aec629ad5479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Thu, 7 Aug 2025 19:07:04 +0200 Subject: [PATCH 08/14] Added Null-check code for every property access step; return null as soon as one step yields null, to guard against non-complete property paths for some instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 27 ++++++++++++++++----------- tests/ObjectExtensionsTests.cs | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index b4575ab..524bc36 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -67,6 +67,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa foreach (Match match in matches) { string part = match.Value; + Expression nextPropertyAccess; // Indexer if (part.StartsWith('[') && part.EndsWith(']')) @@ -79,7 +80,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa if (!indices.All(idx => idx is int)) throw new ArgumentException($"Arrays only support integer indexing, not the provided indexer [{part[1..^1]}]"); - current = AddArrayAccessWithBoundsCheck(current, [.. indices.Select(index => (int)index)]); + nextPropertyAccess = AddArrayAccessWithBoundsCheck(current, [.. indices.Select(index => (int)index)]); } else { @@ -90,25 +91,33 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa // Create constant expressions for each index var indexExpressions = indices.Select(Expression.Constant).ToArray(); - current = Expression.Property(current, indexerProperty, indexExpressions); + nextPropertyAccess = Expression.Property(current, indexerProperty, indexExpressions); } } // Simple property access else - { + { var propertyInfo = current.Type.GetProperty(part, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) ?? throw new ArgumentException($"Property '{part}' not found on type '{current.Type.Name}'"); - current = Expression.Property(current, propertyInfo); + nextPropertyAccess = Expression.Property(current, propertyInfo); } + // Add null check: if current is null, return null; otherwise, evaluate the property access + var notNullCheck = Expression.NotEqual(current, Expression.Constant(null)); + current = Expression.Condition( + notNullCheck, + nextPropertyAccess, + Expression.Constant(null, nextPropertyAccess.Type) + ); + // Compile a lambda of the partial expression thus far (cast to object), to see if we need to add a cast var lambdaTemp = Expression.Lambda>(EnsureObjectCompatibleResult(current), parameterObj); var funcCurrent = lambdaTemp.Compile(); // Evaluate this compiled function, to see if the result type is more specific than the current expression type. If so, cast to it var result = funcCurrent(dataItem); var runtimeType = result?.GetType() ?? current.Type; - if (current.Type != runtimeType - && current.Type.IsAssignableFrom(runtimeType) + if (current.Type != runtimeType + && current.Type.IsAssignableFrom(runtimeType) && !runtimeType.IsValueType // Avoid conversion for value types; the result could be null, causing System.NullReferenceException at runtime ) current = Expression.Convert(current, runtimeType); @@ -151,9 +160,6 @@ private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[ if (indices.Length != rank) throw new ArgumentException($"Array of rank {rank} requires {rank} indices, but {indices.Length} were provided."); - // Null check on the input array - var notNullCheck = Expression.NotEqual(parameterArray, Expression.Constant(null)); - // Bounds check for each dimension Expression boundsCheck = null!; var getLengthMethod = typeof(Array).GetMethod("GetLength")!; @@ -175,8 +181,7 @@ private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[ } // Return the conditional expression directly - this will cause the parent Func<> to return null - var expressionBlock = Expression.Condition( - Expression.AndAlso(notNullCheck, boundsCheck), // null-check and bounds-check + var expressionBlock = Expression.Condition( boundsCheck, Expression.Convert(Expression.ArrayAccess(parameterArray, indexConstants), typeof(object)), Expression.Constant(null, typeof(object)) ); diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index 9b01222..2825028 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -93,13 +93,28 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty3() } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidIndexer() + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty4() { var testItem = new TestItem(); var func = testItem.GetFuncCompiledPropertyPath("Dictionary[123]"); Assert.IsNull(func); } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidArrayIndex() + { + var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; + var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Name"); + Assert.IsNotNull(func); + + var result = func(testItem); + Assert.AreEqual("NestedValue", result); + + testItem = new TestItem { SubItems = [new() { SubSubItems = null! }] }; + result = func(testItem); + Assert.IsNull(result); + } + [TestMethod] public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsArrayIndex() { From c623f925e447e4248de5e35bcdde107695789012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Thu, 7 Aug 2025 21:07:34 +0200 Subject: [PATCH 09/14] Added logic for IDict/IList/ICollection/generic indexer, but not yet tests for all possible cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 193 ++++++++++++++++++++++++++--- tests/ObjectExtensionsTests.cs | 15 +++ 2 files changed, 194 insertions(+), 14 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 524bc36..e20db52 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -76,7 +77,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa if (current.Type.IsArray) { - // Arrays only support single integer indexing + // Arrays only support integer indexing if (!indices.All(idx => idx is int)) throw new ArgumentException($"Arrays only support integer indexing, not the provided indexer [{part[1..^1]}]"); @@ -84,14 +85,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa } else { - // Try to find an indexer property with the appropriate parameter types - var indexerTypes = indices.Select(idx => idx.GetType()).ToArray(); - var indexerProperty = current.Type.GetProperty("Item", indexerTypes) - ?? throw new ArgumentException($"Type '{current.Type.Name}' does not support indexing with types: {string.Join(", ", indexerTypes.Select(t => t.Name))}"); - - // Create constant expressions for each index - var indexExpressions = indices.Select(Expression.Constant).ToArray(); - nextPropertyAccess = Expression.Property(current, indexerProperty, indexExpressions); + nextPropertyAccess = AddIndexerAccessWithSafetyChecks(current, indices); } } // Simple property access @@ -132,7 +126,7 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa private static object[] GetIndices(string stringIndexer) { // Split by comma and parse each indexer, removing whitespace - var indexerParts = stringIndexer.Split(',', StringSplitOptions.RemoveEmptyEntries) + var indexerParts = stringIndexer.Split(',') .Select(s => s.Trim()) .ToArray(); @@ -146,7 +140,7 @@ private static object[] GetIndices(string stringIndexer) /// Adds array access to the current expression, given the inputted indices, and adds bounds checking; /// the code for this expression will return null if the indices are out of bounds. /// - private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[] indices) + private static BlockExpression AddArrayAccessWithBoundsCheck(Expression current, int[] indices) { if (!current.Type.IsArray) throw new ArgumentException("Current expression must be an array."); @@ -180,15 +174,16 @@ private static Expression AddArrayAccessWithBoundsCheck(Expression current, int[ boundsCheck = boundsCheck == null ? dimCheck : Expression.AndAlso(boundsCheck, dimCheck); } - // Return the conditional expression directly - this will cause the parent Func<> to return null - var expressionBlock = Expression.Condition( boundsCheck, + // If bounds check is not satisfied, return null; otherwise, access the array element + var expressionBlock = Expression.Condition( + boundsCheck, Expression.Convert(Expression.ArrayAccess(parameterArray, indexConstants), typeof(object)), Expression.Constant(null, typeof(object)) ); // Add the block return Expression.Block( - new[] { parameterArray }, + [parameterArray], assignArray, expressionBlock ); @@ -200,6 +195,176 @@ private static Expression EnsureObjectCompatibleResult(Expression expression) => ? expression : Expression.Convert(expression, typeof(object)); + /// + /// Adds safe indexer access to the current expression, with appropriate bounds/key checking for various collection types. + /// Returns null if the indexer access would fail (out of bounds, missing key, etc.). + /// + private static Expression AddIndexerAccessWithSafetyChecks(Expression current, object[] indices) + { + var currentType = current.Type; + var parameter = Expression.Parameter(currentType, "collection"); + var assignCurrent = Expression.Assign(parameter, current); + + // Handle IDictionary - use TryGetValue + if (TryCreateDictionaryTryGetExpression(currentType, parameter, indices, out var dictionaryExpr)) + { + return Expression.Block([parameter], assignCurrent, dictionaryExpr); + } + + // Handle (generic) IList and ICollection - bounds checking + if (TryCreateIListOrICollectionBoundsCheckExpression(currentType, parameter, indices, out var listExpr)) + { + return Expression.Block([parameter], assignCurrent, listExpr); + } + + // Handle generic indexers - check if indexer exists + if (TryCreateGenericIndexerExpression(currentType, parameter, indices, out var indexerExpr)) + { + return Expression.Block([parameter], assignCurrent, indexerExpr); + } + + // If no safe indexer can be created, return null + return Expression.Constant(null, typeof(object)); + } + + /// + /// Creates a TryGetValue expression for IDictionary types. + /// + private static bool TryCreateDictionaryTryGetExpression(Type type, ParameterExpression parameter, object[] indices, out Expression expression) + { + expression = null!; + + if (indices.Length != 1) + return false; + + // Find IDictionary interface + var dictionaryInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + + if (dictionaryInterface == null) + return false; //apparently not a dictionary + + var genericArgs = dictionaryInterface.GetGenericArguments(); + var keyType = genericArgs[0]; + var valueType = genericArgs[1]; + + // Check if the index type matches the key type + if (!keyType.IsAssignableFrom(indices[0].GetType())) + { + // Type mismatch - return an expression that always returns null + expression = Expression.Constant(null, typeof(object)); + return true; + } + + // Get TryGetValue method + var tryGetValueMethod = dictionaryInterface.GetMethod("TryGetValue"); + if (tryGetValueMethod == null) + throw new InvalidOperationException($"The dictionary type {type} has no TryGetValue method"); // should not happen + + // Create variables for the key and output value + var keyExpr = Expression.Constant(indices[0], keyType); + var valueVar = Expression.Parameter(valueType, "value"); + + // Call TryGetValue + var tryGetCall = Expression.Call(parameter, tryGetValueMethod, keyExpr, valueVar); + + // Return value if found, null if not found + expression = Expression.Block( + [valueVar], + Expression.Condition( + tryGetCall, + Expression.Convert(valueVar, typeof(object)), + Expression.Constant(null, typeof(object)) + ) + ); + + return true; + } + + /// + /// Creates a bounds-checked expression for IList and ICollection types. + /// + private static bool TryCreateIListOrICollectionBoundsCheckExpression(Type type, ParameterExpression parameter, object[] indices, out Expression expression) + { + expression = null!; + + if (indices.Length != 1 || indices[0] is not int index) + return false; + + // Try to find an indexer property with the appropriate parameter types + var indexerProperty = type.GetProperty("Item", [typeof(int)]); + if (indexerProperty == null) + return false; // No indexer found + + // Check for IList + var listInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && + ( i.GetGenericTypeDefinition() == typeof(IList<>) || + i.GetGenericTypeDefinition() == typeof(ICollection<>) + )); + + if (listInterface != null || + typeof(IList).IsAssignableFrom(type) || // Does have an indexer + typeof(ICollection).IsAssignableFrom(type) // Has no indexer, but we can still check bounds; since it derives from ICollection at the least, the indexer is expected to be aimed at the collection + ) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(indices), $"Index for (generic) IList/ICollection cannot be negative: {index}"); + + var countProperty = type.GetProperty("Count"); + + if (indexerProperty != null && countProperty != null) + { + var indexExpr = Expression.Constant(index); + var countExpr = Expression.Property(parameter, countProperty); + var boundsCheck = Expression.LessThan(indexExpr, countExpr); + + expression = Expression.Condition( + boundsCheck, + Expression.Convert(Expression.Property(parameter, indexerProperty, indexExpr), typeof(object)), + Expression.Constant(null, typeof(object)) + ); + + return true; + } + } + + return false; + } + + /// + /// Creates an expression for generic indexers, with try-catch to handle potential exceptions. + /// Returns null if indexer access fails for any reason. + /// + private static bool TryCreateGenericIndexerExpression(Type type, ParameterExpression parameter, object[] indices, out Expression expression) + { + expression = null!; + + // Try to find an indexer property with the appropriate parameter types + var indexerTypes = indices.Select(idx => idx.GetType()).ToArray(); + var indexerProperty = type.GetProperty("Item", indexerTypes); + + if (indexerProperty == null) + return false; + + // Create constant expressions for each index + var indexExpressions = indices.Select(Expression.Constant).ToArray(); + + // Create the indexer access + var indexerAccess = Expression.Property(parameter, indexerProperty, indexExpressions); + + // Wrap in try-catch to handle any exceptions that might occur during indexer access, due to non-existing keys or out-of-bounds indices + var tryBlock = Expression.Convert(indexerAccess, typeof(object)); + var catchBlock = Expression.Constant(null, typeof(object)); + + // Create try-catch expression - return null for any exception + expression = Expression.TryCatch( + tryBlock, + Expression.Catch(typeof(Exception), catchBlock) + ); + + return true; + } /// /// Determines the type of items contained within the specified . diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index 2825028..ccf2b90 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -100,6 +100,21 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty4() Assert.IsNull(func); } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidDictionaryIndexer() + { + var testItem = new TestItem { Dictionary2 = new() { { 1, "value1" } } }; + var func = testItem.GetFuncCompiledPropertyPath("Dictionary2[1]"); + Assert.IsNotNull(func); + + var result = func(testItem); + Assert.AreEqual("value1", result); + + testItem = new TestItem { Dictionary2 = new() { { 2, "value2" } } }; + result = func(testItem); + Assert.IsNull(result); + } + [TestMethod] public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidArrayIndex() { From fd1bf2622aa3c3b9b44ba9e8dbe04b1cdfe3c7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Fri, 8 Aug 2025 07:26:24 +0200 Subject: [PATCH 10/14] Added the simplest test; access a property directly on the instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 39 ++++++++++++++++++------------ tests/ObjectExtensionsTests.cs | 13 +++++++++- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index e20db52..45f5757 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -93,16 +93,25 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa { var propertyInfo = current.Type.GetProperty(part, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) ?? throw new ArgumentException($"Property '{part}' not found on type '{current.Type.Name}'"); + nextPropertyAccess = Expression.Property(current, propertyInfo); } - // Add null check: if current is null, return null; otherwise, evaluate the property access - var notNullCheck = Expression.NotEqual(current, Expression.Constant(null)); - current = Expression.Condition( - notNullCheck, - nextPropertyAccess, - Expression.Constant(null, nextPropertyAccess.Type) - ); + if (IsObjectCompatible(nextPropertyAccess.Type)) + { + // Add null check: if current is null, stop and return null; otherwise, continue with the next access + var notNullCheck = Expression.NotEqual(current, Expression.Constant(null)); + current = Expression.Condition( + notNullCheck, + nextPropertyAccess, + Expression.Constant(null, nextPropertyAccess.Type) + ); + } + else + { + // Value types cannot be null, so don't need to check for null, and we can directly assign the property access + current = nextPropertyAccess; + } // Compile a lambda of the partial expression thus far (cast to object), to see if we need to add a cast var lambdaTemp = Expression.Lambda>(EnsureObjectCompatibleResult(current), parameterObj); @@ -175,7 +184,7 @@ private static BlockExpression AddArrayAccessWithBoundsCheck(Expression current, } // If bounds check is not satisfied, return null; otherwise, access the array element - var expressionBlock = Expression.Condition( + var expressionBlock = Expression.Condition( boundsCheck, Expression.Convert(Expression.ArrayAccess(parameterArray, indexConstants), typeof(object)), Expression.Constant(null, typeof(object)) @@ -190,10 +199,10 @@ private static BlockExpression AddArrayAccessWithBoundsCheck(Expression current, } - private static Expression EnsureObjectCompatibleResult(Expression expression) => - typeof(object).IsAssignableFrom(expression.Type) && !expression.Type.IsValueType - ? expression - : Expression.Convert(expression, typeof(object)); + private static Expression EnsureObjectCompatibleResult(Expression expression) => + IsObjectCompatible(expression.Type) ? expression : Expression.Convert(expression, typeof(object)); + + private static bool IsObjectCompatible(Type typeToCheck) => typeof(object).IsAssignableFrom(typeToCheck) && !typeToCheck.IsValueType; /// /// Adds safe indexer access to the current expression, with appropriate bounds/key checking for various collection types. @@ -298,12 +307,12 @@ private static bool TryCreateIListOrICollectionBoundsCheckExpression(Type type, // Check for IList var listInterface = type.GetInterfaces() - .FirstOrDefault(i => i.IsGenericType && - ( i.GetGenericTypeDefinition() == typeof(IList<>) || + .FirstOrDefault(i => i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IList<>) || i.GetGenericTypeDefinition() == typeof(ICollection<>) )); - if (listInterface != null || + if (listInterface != null || typeof(IList).IsAssignableFrom(type) || // Does have an indexer typeof(ICollection).IsAssignableFrom(type) // Has no indexer, but we can still check bounds; since it derives from ICollection at the least, the indexer is expected to be aimed at the collection ) diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index ccf2b90..0fb9ef0 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -8,7 +8,17 @@ namespace WinUI.TableView.Tests; public class ObjectExtensionsTests { [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessSimpleNestedProperty() + public void GetFuncCompiledPropertyPath_ShouldAccessSimpleProperty() + { + var testItem = new TestItem { Number = 7 }; + var func = testItem.GetFuncCompiledPropertyPath("Number"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual(7, result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccessNestedProperty() { var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Name"); @@ -214,6 +224,7 @@ private class SubItem private class TestItem { + public int Number { get; set; } = 0; public List SubItems { get; set; } = []; public Dictionary Dictionary1 { get; set; } = []; public Dictionary Dictionary2 { get; set; } = []; From 0c2b84c9dee4b7588b32d6080f8560b3e8cc2150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Fri, 8 Aug 2025 07:53:49 +0200 Subject: [PATCH 11/14] Test case for List, and edge cases where null should be returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- tests/ObjectExtensionsTests.cs | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index 0fb9ef0..6481c22 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; using WinUI.TableView.Extensions; @@ -164,6 +165,106 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsMultiDimA Assert.IsNull(result); } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccessListByIndex() + { + var testItem = new TestItem { StringList = ["item0", "item1", "item2"] }; + var func = testItem.GetFuncCompiledPropertyPath("StringList[1]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual("item1", result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsListIndex() + { + var testItem = new TestItem { StringList = ["item0", "item1", "item2"] }; + var func = testItem.GetFuncCompiledPropertyPath("StringList[2]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual("item2", result); + + // Test with different data that has fewer items - should return null + testItem = new TestItem { StringList = ["item0"] }; + result = func(testItem); + Assert.IsNull(result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForDictionaryKeyTypeMismatch() + { + // Dictionary accessed with int key + var testItem = new TestItem { Dictionary1 = new() { { "key1", "value1" } } }; + var func = testItem.GetFuncCompiledPropertyPath("Dictionary1[123]"); // int key for string-keyed dictionary + Assert.IsNotNull(func); + var result = func(testItem); + Assert.IsNull(result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccessValueTypeProperty() + { + var testItem = new TestItem { ValueTypeStruct = new TestStruct { Value = 42 } }; + var func = testItem.GetFuncCompiledPropertyPath("ValueTypeStruct.Value"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual(42, result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForNegativeArrayIndex() + { + var testItem = new TestItem { IntArray = [10, 20, 30] }; + var func = testItem.GetFuncCompiledPropertyPath("IntArray[-1]"); + Assert.IsNull(func); // Should fail during expression building + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForNegativeListIndex() + { + var testItem = new TestItem { StringList = ["item0", "item1"] }; + var func = testItem.GetFuncCompiledPropertyPath("StringList[-1]"); + Assert.IsNull(func); // Should fail during expression building + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForWrongArrayDimensions() + { + var testItem = new TestItem { Int2DArray = new int[,] { { 1, 2 }, { 3, 4 } } }; + var func = testItem.GetFuncCompiledPropertyPath("Int2DArray[1]"); // 2D array with 1D index + Assert.IsNull(func); // Should fail during expression building + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccessNonGenericList() + { + var testItem = new TestItem { NonGenericList = new System.Collections.ArrayList { "item0", "item1", "item2" } }; + var func = testItem.GetFuncCompiledPropertyPath("NonGenericList[1]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual("item1", result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsNonGenericList() + { + var testItem = new TestItem { NonGenericList = new System.Collections.ArrayList { "item0" } }; + var func = testItem.GetFuncCompiledPropertyPath("NonGenericList[5]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.IsNull(result); + } + + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForEmptyList() + { + var testItem = new TestItem { StringList = [] }; + var func = testItem.GetFuncCompiledPropertyPath("StringList[0]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.IsNull(result); + } + [TestMethod] public void GetItemType_ShouldReturnCorrectType_ForGenericEnumerable() { @@ -225,11 +326,14 @@ private class SubItem private class TestItem { public int Number { get; set; } = 0; + public TestStruct ValueTypeStruct { get; set; } + public IList NonGenericList { get; set; } = new ArrayList(); public List SubItems { get; set; } = []; public Dictionary Dictionary1 { get; set; } = []; public Dictionary Dictionary2 { get; set; } = []; public int[] IntArray { get; set; } = []; public int[,] Int2DArray { get; set; } = new int[0, 0]; + public List StringList { get; set; } = []; // Multi-dimensional indexer private readonly Dictionary<(int, string), string> _multiIndex = new(); @@ -239,4 +343,10 @@ private class TestItem set => _multiIndex[(i, key)] = value; } } + + public struct TestStruct + { + public int Value { get; set; } + } } + From b69b9d55fb79c0bf3cc0b5aea90b66b8febe8ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Sat, 9 Aug 2025 11:06:06 +0200 Subject: [PATCH 12/14] Added a new test for the ultimate try/catch case for indexers that cannot be bound-checked, and do not have a TryGetValue construct like with dictionary. We could add code to search for such a method, but there is not guarentee that is the method we should use... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- tests/ObjectExtensionsTests.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index 6481c22..a81397b 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -235,6 +235,17 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForWrongArrayDimensions Assert.IsNull(func); // Should fail during expression building } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForThrowingIndexer() + { + var testItem = new TestItem(); + // This should trigger the generic indexer path with try-catch + var func = testItem.GetFuncCompiledPropertyPath("[999,nonexistent]"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.IsNull(result); // Custom indexer returns empty string, but expression should handle gracefully + } + [TestMethod] public void GetFuncCompiledPropertyPath_ShouldAccessNonGenericList() { @@ -339,7 +350,7 @@ private class TestItem private readonly Dictionary<(int, string), string> _multiIndex = new(); public string this[int i, string key] { - get => _multiIndex.TryGetValue((i, key), out var value) ? value : string.Empty; + get => _multiIndex[(i, key)]; set => _multiIndex[(i, key)] = value; } } From 1cc3e34285bf0d436922591178da3dd7701b9579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Sat, 9 Aug 2025 11:12:23 +0200 Subject: [PATCH 13/14] Some optimizations; - An explicit conversion is not always needed when an implicit conversion would take care of it - The in-loop compilation (used to determine the need for conversion) is not needed for the last match (Typically, Binding path is just accessing one property, and thus the inloop compilation will not be used, saving some time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniël Trommel --- src/Extensions/ObjectExtensions.cs | 64 ++++++++++++++++-------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 45f5757..2115ffd 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -29,14 +29,14 @@ internal static partial class ObjectExtensions { // Build the property access expression chain with runtime type checking var parameterObj = Expression.Parameter(typeof(object), "obj"); - var propertyAccess = BuildPropertyPathExpressionTree(parameterObj, bindingPath, dataItem); + var expressionTree = BuildPropertyPathExpressionTree(parameterObj, bindingPath, dataItem); - // Compile the lambda expression - var lambda = Expression.Lambda>(Expression.Convert(propertyAccess, typeof(object)), parameterObj); + if (NeedToConvert(expressionTree.Type, typeof(object))) + expressionTree = Expression.Convert(expressionTree, typeof(object)); - // To debug, set a breakpoint below and inspect the "DebugView" property on "propertyAccess" - var _compiledPropertyPath = lambda.Compile(); - return _compiledPropertyPath; + // Compile the lambda expression + var lambda = Expression.Lambda>(expressionTree, parameterObj); + return lambda.Compile(); // To DEBUG, set a breakpoint here and inspect the "DebugView" property on the variable "lambda" } catch { @@ -58,8 +58,8 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa // The function uses a generic object input parameter to allow for any type of data item, // but we need to ensure that the runtime type matches the data item type that is inputted as example to be able to find members { - var type = dataItem.GetType(); - if (current.Type != type && !type.IsValueType) + var typeActual = dataItem.GetType(); + if (current.Type != typeActual && !typeActual.IsValueType) current = Expression.Convert(current, dataItem.GetType()); } @@ -97,7 +97,12 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa nextPropertyAccess = Expression.Property(current, propertyInfo); } - if (IsObjectCompatible(nextPropertyAccess.Type)) + if (nextPropertyAccess.Type.IsValueType && !nextPropertyAccess.Type.IsNullableType()) + { + // Value types cannot be null, so don't need to check for null, and we can directly assign the property access + current = nextPropertyAccess; + } + else { // Add null check: if current is null, stop and return null; otherwise, continue with the next access var notNullCheck = Expression.NotEqual(current, Expression.Constant(null)); @@ -107,28 +112,34 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa Expression.Constant(null, nextPropertyAccess.Type) ); } - else + + // Only check for type compatibility (i.e.: the need for conversion) if this is not the last match + if (match != matches[^1]) { - // Value types cannot be null, so don't need to check for null, and we can directly assign the property access - current = nextPropertyAccess; + // Compile a lambda of the partial expression thus far (cast to object), to see if we need to add a cast + var lambdaTemp = Expression.Lambda>(EnsureObjectCompatibleResult(current), parameterObj); + var funcCurrent = lambdaTemp.Compile(); + // Evaluate this compiled function, to see if the result type is more specific than the current expression type. If so, cast to it + var result = funcCurrent(dataItem); + var typeResult = result?.GetType() ?? current.Type; + if (NeedToConvert(current.Type, typeResult)) + current = Expression.Convert(current, typeResult); } - - // Compile a lambda of the partial expression thus far (cast to object), to see if we need to add a cast - var lambdaTemp = Expression.Lambda>(EnsureObjectCompatibleResult(current), parameterObj); - var funcCurrent = lambdaTemp.Compile(); - // Evaluate this compiled function, to see if the result type is more specific than the current expression type. If so, cast to it - var result = funcCurrent(dataItem); - var runtimeType = result?.GetType() ?? current.Type; - if (current.Type != runtimeType - && current.Type.IsAssignableFrom(runtimeType) - && !runtimeType.IsValueType // Avoid conversion for value types; the result could be null, causing System.NullReferenceException at runtime - ) - current = Expression.Convert(current, runtimeType); } return EnsureObjectCompatibleResult(current); } + private static bool NeedToConvert(Type typeFrom, Type typeTo) => + typeFrom != typeTo && + typeFrom.IsAssignableFrom(typeTo) && + !typeTo.IsValueType; // Avoid conversion for value types; the result could be null, causing System.NullReferenceException at runtime + + private static Expression EnsureObjectCompatibleResult(Expression expression) => + IsObjectCompatible(expression.Type) ? expression : Expression.Convert(expression, typeof(object)); + + private static bool IsObjectCompatible(Type typeToCheck) => typeof(object).IsAssignableFrom(typeToCheck) && !typeToCheck.IsValueType; + /// /// Returns the indices from a (possible multi-dimensional) indexer that may be a mixture of integers and strings. /// @@ -199,11 +210,6 @@ private static BlockExpression AddArrayAccessWithBoundsCheck(Expression current, } - private static Expression EnsureObjectCompatibleResult(Expression expression) => - IsObjectCompatible(expression.Type) ? expression : Expression.Convert(expression, typeof(object)); - - private static bool IsObjectCompatible(Type typeToCheck) => typeof(object).IsAssignableFrom(typeToCheck) && !typeToCheck.IsValueType; - /// /// Adds safe indexer access to the current expression, with appropriate bounds/key checking for various collection types. /// Returns null if the indexer access would fail (out of bounds, missing key, etc.). From dfa60d66bb8a1e1a7d004969958beb6783401f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Trommel?= Date: Sat, 9 Aug 2025 12:47:19 +0200 Subject: [PATCH 14/14] Another test case; accessing a property on a string. And further refinement of the conversion logic --- src/Extensions/ObjectExtensions.cs | 28 +++++++++++++++------------- tests/ObjectExtensionsTests.cs | 10 ++++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 2115ffd..eee2500 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -31,9 +31,6 @@ internal static partial class ObjectExtensions var parameterObj = Expression.Parameter(typeof(object), "obj"); var expressionTree = BuildPropertyPathExpressionTree(parameterObj, bindingPath, dataItem); - if (NeedToConvert(expressionTree.Type, typeof(object))) - expressionTree = Expression.Convert(expressionTree, typeof(object)); - // Compile the lambda expression var lambda = Expression.Lambda>(expressionTree, parameterObj); return lambda.Compile(); // To DEBUG, set a breakpoint here and inspect the "DebugView" property on the variable "lambda" @@ -122,23 +119,28 @@ private static Expression BuildPropertyPathExpressionTree(ParameterExpression pa // Evaluate this compiled function, to see if the result type is more specific than the current expression type. If so, cast to it var result = funcCurrent(dataItem); var typeResult = result?.GetType() ?? current.Type; - if (NeedToConvert(current.Type, typeResult)) + + if (current.Type != typeResult) + { + // Note that we do not need to check for null before we convert, as the null check is already done in the previous condition + // So, we can safely convert the expression to the result type, even for value types (without the null check, a conversion of null to e.g. an int would result in a NullException being thrown) current = Expression.Convert(current, typeResult); + } } } return EnsureObjectCompatibleResult(current); } - private static bool NeedToConvert(Type typeFrom, Type typeTo) => - typeFrom != typeTo && - typeFrom.IsAssignableFrom(typeTo) && - !typeTo.IsValueType; // Avoid conversion for value types; the result could be null, causing System.NullReferenceException at runtime - - private static Expression EnsureObjectCompatibleResult(Expression expression) => - IsObjectCompatible(expression.Type) ? expression : Expression.Convert(expression, typeof(object)); - - private static bool IsObjectCompatible(Type typeToCheck) => typeof(object).IsAssignableFrom(typeToCheck) && !typeToCheck.IsValueType; + private static Expression EnsureObjectCompatibleResult(Expression expression) + { + // Only convert to object if the expression type is not already assignable to object without boxing + if (expression.Type.IsValueType + && !expression.Type.IsNullableType() // e.g. int? is considered a ValueType, but also nullable, which means it can be converted to object without boxing + ) + return Expression.Convert(expression, typeof(object)); + return expression; + } /// /// Returns the indices from a (possible multi-dimensional) indexer that may be a mixture of integers and strings. diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index a81397b..5041859 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -175,6 +175,16 @@ public void GetFuncCompiledPropertyPath_ShouldAccessListByIndex() Assert.AreEqual("item1", result); } + [TestMethod] + public void GetFuncCompiledPropertyPath_ShouldAccessPropertyOnString() + { + var testItem = new TestItem { StringList = ["item0", "item1 long text", "item2"] }; + var func = testItem.GetFuncCompiledPropertyPath("StringList[1].Length"); + Assert.IsNotNull(func); + var result = func(testItem); + Assert.AreEqual(15, result); + } + [TestMethod] public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsListIndex() {