diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index f8eb5f88..d94aaa2c 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -1220,6 +1220,56 @@ public void ElseWithCustomBlockHelperInvocationChainsThreeDeep() Assert.Equal("D", template(new { value = "z" })); } + public interface Issue601_IBaseData + { + DateTime DateTimeUtc { get; } + } + + public interface Issue601_IData : Issue601_IBaseData + { + string DateTimeStr => DateTimeUtc.ToString("O"); + } + + public class Issue601_Data : Issue601_IData + { + public DateTime DateTimeUtc { get; set; } + public string OtherStr => DateTimeUtc.ToString("D"); + } + + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/601 + // A property whose only implementation is a C# 8+ default interface member (declared and + // bodied on the interface, not overridden by the concrete class) must still be resolvable + // by name, even though it's invisible to Type.GetProperties() on the concrete class. + [Fact] + public void Issue601_DefaultInterfaceMemberPropertyIsResolved() + { + var handlebars = Handlebars.Create(); + var template = handlebars.Compile("{{DateTimeStr}}|{{OtherStr}}"); + + var data = new Issue601_Data { DateTimeUtc = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc) }; + Issue601_IData dataAsInterface = data; + var result = template(data); + + Assert.Equal($"{dataAsInterface.DateTimeStr}|{data.OtherStr}", result); + } + + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/601 + // Enumerating a POCO's members (e.g. via {{#each this}}) must also surface + // default-interface-member-backed properties, not just class-declared ones. + [Fact] + public void Issue601_DefaultInterfaceMemberPropertyIsEnumerated() + { + var handlebars = Handlebars.Create(); + var template = handlebars.Compile("{{#each this}}{{@key}}={{this}};{{/each}}"); + + var data = new Issue601_Data { DateTimeUtc = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc) }; + Issue601_IData dataAsInterface = data; + var result = template(data); + + Assert.Contains($"DateTimeStr={dataAsInterface.DateTimeStr};", result); + Assert.Contains($"OtherStr={data.OtherStr};", result); + } + private static void RegisterStringEqualityBlockHelper(IHandlebars handlebars) { handlebars.RegisterHelper("StringEqualityBlockHelper", (output, options, context, arguments) => diff --git a/source/Handlebars/MemberAccessors/ReflectionMemberAccessor.cs b/source/Handlebars/MemberAccessors/ReflectionMemberAccessor.cs index 87b69fdd..fbcd5286 100644 --- a/source/Handlebars/MemberAccessors/ReflectionMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/ReflectionMemberAccessor.cs @@ -135,6 +135,15 @@ public RawObjectTypeDescriptor(Type type) string.Equals(o.Name, name.LowerInvariant, StringComparison.OrdinalIgnoreCase) ); + // Properties implemented purely as C# 8+ default interface members (no override on the + // concrete class) don't appear via reflection on the class itself, only on the interface. + property ??= type.GetInterfaces() + .SelectMany(o => o.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + .FirstOrDefault(o => + o.GetIndexParameters().Length == 0 && + string.Equals(o.Name, name.LowerInvariant, StringComparison.OrdinalIgnoreCase) + ); + if (property != null) { return (Func) CreateGetDelegateMethodInfo diff --git a/source/Handlebars/ObjectDescriptors/ObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/ObjectDescriptorProvider.cs index 61bec5c9..1c36f236 100644 --- a/source/Handlebars/ObjectDescriptors/ObjectDescriptorProvider.cs +++ b/source/Handlebars/ObjectDescriptors/ObjectDescriptorProvider.cs @@ -42,12 +42,20 @@ public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor { var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(o => o.CanRead && o.GetIndexParameters().Length == 0); - + + // Properties implemented purely as C# 8+ default interface members (no override on the + // concrete class) don't appear via reflection on the class itself, only on the interface. + var interfaceProperties = type.GetInterfaces() + .SelectMany(o => o.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Where(o => o.CanRead && o.GetIndexParameters().Length == 0); + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); return properties + .Concat(interfaceProperties) .Cast() .Concat(fields) .Select(o => o.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) .Select(o => ChainSegment.Create(o)) .ToArray(); });