diff --git a/AGENTS.md b/AGENTS.md index 936452f2..dcf82636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Pipeline: **source text → Parlot grammar → `Statement` AST → async render **Options vs context** — `TemplateOptions` is shared, effectively-immutable application configuration (filters, member access strategy, culture, time zone, execution limits) and should be created once. `TemplateContext` is per-render and **not** thread-safe; it owns the scope chain, the model, and the step/recursion counters. `FluidParser` and `IFluidTemplate` instances are thread-safe and meant to be cached. -**Security / member access** — Fluid is allow-list based: a .NET member is invisible to templates until registered. `MemberAccessStrategy` + `IMemberAccessor` (`Fluid/Accessors/`) resolve members, with emit-based accessors when dynamic code is available and `Reflection*Accessor` fallbacks otherwise (NativeAOT/trimming). `Fluid.SourceGenerator/MemberAccessorGenerator.cs` generates accessors for types marked with `[FluidRegister]`; the `TemplateOptions` constructor invokes them via `RegisterGeneratedMemberAccessors()`. Changes to accessor resolution need to hold for all three paths — emit, reflection, generated. +**Security / member access** — Fluid is allow-list based: a .NET member is invisible to templates until registered. `MemberAccessStrategy` + `MemberAccessor` resolve members, with emit-based accessors when dynamic code is available and `Reflection*Accessor` fallbacks otherwise (NativeAOT/trimming). `Fluid.SourceGenerator/MemberAccessorGenerator.cs` generates accessors for types marked with `[FluidRegister]`; the `TemplateOptions` constructor invokes them via `RegisterGeneratedMemberAccessors()`. Changes to accessor resolution need to hold for all three paths — emit, reflection, generated. **Filters** — `FilterCollection` maps a name to a `FilterDelegate`. Built-ins live in `Fluid/Filters/{Array,String,Number,Misc,Color,Money}Filters.cs` and are wired up by `With*Filters()` extension methods. The `TemplateOptions` constructor calls `WithArrayFilters().WithStringFilters().WithNumberFilters().WithMiscFilters()`; `WithColorFilters()` and `WithMoneyFilters()` exist but are opt-in. A new built-in filter goes in the matching file plus its `With*Filters` method. `WithMoneyFilters()` is configured by `TemplateOptions.MoneyOptions` (`Fluid/MoneyOptions.cs`), which is mirrored on `TemplateContext` so a currency can be picked per render. diff --git a/Fluid.SourceGenerator/MemberAccessorGenerator.cs b/Fluid.SourceGenerator/MemberAccessorGenerator.cs index d7962e16..1f468508 100644 --- a/Fluid.SourceGenerator/MemberAccessorGenerator.cs +++ b/Fluid.SourceGenerator/MemberAccessorGenerator.cs @@ -480,12 +480,12 @@ private static string GetAccessibilityKeyword(Accessibility accessibility) private static void AppendAccessor(StringBuilder source, string accessorName, string typeExpression, List members) { - source.Append(" internal sealed class ").Append(accessorName).AppendLine(" : global::Fluid.IAsyncMemberAccessor"); + source.Append(" internal sealed class ").Append(accessorName).AppendLine(" : global::Fluid.MemberAccessor"); source.AppendLine(" {"); - source.AppendLine(" public object Get(object obj, string name, global::Fluid.TemplateContext ctx)"); + source.AppendLine(" public override global::System.Threading.Tasks.ValueTask GetAsync(object obj, string name, global::Fluid.TemplateContext context)"); source.AppendLine(" {"); source.Append(" var typed = (").Append(typeExpression).AppendLine(")obj;"); - source.AppendLine(" var comparer = ctx.Options.ModelNamesComparer;"); + source.AppendLine(" var comparer = context.Options.ModelNamesComparer;"); source.AppendLine(); foreach (var member in members) @@ -494,58 +494,12 @@ private static void AppendAccessor(StringBuilder source, string accessorName, st .Append(member.Name) .AppendLine("\"))"); source.AppendLine(" {"); - source.Append(" return ").Append(member.Expression).AppendLine(";"); + source.Append(" return CreateValueTask(").Append(member.Expression).AppendLine(", context);"); source.AppendLine(" }"); } source.AppendLine(); - source.AppendLine(" return null;"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine(" public async global::System.Threading.Tasks.Task GetAsync(object obj, string name, global::Fluid.TemplateContext ctx)"); - source.AppendLine(" {"); - source.Append(" var typed = (").Append(typeExpression).AppendLine(")obj;"); - source.AppendLine(" var comparer = ctx.Options.ModelNamesComparer;"); - source.AppendLine(); - - foreach (var member in members) - { - source.Append(" if (comparer.Equals(name, \"") - .Append(member.Name) - .AppendLine("\"))"); - source.AppendLine(" {"); - - switch (member.AsyncKind) - { - case AsyncKind.Task: - source.Append(" var task = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" await task.ConfigureAwait(false);"); - source.AppendLine(" return task.Result;"); - break; - case AsyncKind.ValueTask: - source.Append(" var valueTask = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" return await valueTask.ConfigureAwait(false);"); - break; - case AsyncKind.TaskWithoutResult: - source.Append(" var task = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" await task.ConfigureAwait(false);"); - source.AppendLine(" return null;"); - break; - case AsyncKind.ValueTaskWithoutResult: - source.Append(" var valueTask = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" await valueTask.ConfigureAwait(false);"); - source.AppendLine(" return null;"); - break; - default: - source.Append(" return ").Append(member.Expression).AppendLine(";"); - break; - } - - source.AppendLine(" }"); - } - - source.AppendLine(); - source.AppendLine(" return null;"); + source.AppendLine(" return default;"); source.AppendLine(" }"); source.AppendLine(" }"); } @@ -572,7 +526,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}" : $"typed.{memberName}"; - members.Add(new MemberAccess(property.Name, expression, GetAsyncKind(property.Type))); + members.Add(new MemberAccess(property.Name, expression)); } foreach (var field in EnumerateFields(typeSymbol)) @@ -592,7 +546,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}" : $"typed.{memberName}"; - members.Add(new MemberAccess(field.Name, expression, GetAsyncKind(field.Type))); + members.Add(new MemberAccess(field.Name, expression)); } foreach (var method in EnumerateMethods(typeSymbol)) @@ -612,7 +566,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}()" : $"typed.{memberName}()"; - members.Add(new MemberAccess(method.Name, expression, GetAsyncKind(method.ReturnType))); + members.Add(new MemberAccess(method.Name, expression)); } return members; @@ -701,29 +655,6 @@ private static IEnumerable EnumerateMembers(ITypeSymbol typeSymbol) } } - private static AsyncKind GetAsyncKind(ITypeSymbol type) - { - if (type is not INamedTypeSymbol namedType) - { - return AsyncKind.None; - } - - var containingNamespace = namedType.ContainingNamespace?.ToDisplayString(); - if (!string.Equals(containingNamespace, "System.Threading.Tasks", StringComparison.Ordinal)) - { - return AsyncKind.None; - } - - return namedType.Name switch - { - "Task" when namedType.IsGenericType => AsyncKind.Task, - "Task" => AsyncKind.TaskWithoutResult, - "ValueTask" when namedType.IsGenericType => AsyncKind.ValueTask, - "ValueTask" => AsyncKind.ValueTaskWithoutResult, - _ => AsyncKind.None - }; - } - private static string CreateAccessorName(ITypeSymbol typeSymbol, HashSet usedAccessorNames) { var baseName = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); @@ -797,16 +728,7 @@ private sealed record GeneratedOptionsTypeRegistration( INamedTypeSymbol OptionsType, ImmutableArray Accessors); - private sealed record MemberAccess(string Name, string Expression, AsyncKind AsyncKind); - - private enum AsyncKind - { - None, - Task, - ValueTask, - TaskWithoutResult, - ValueTaskWithoutResult - } + private sealed record MemberAccess(string Name, string Expression); private static readonly string AttributeSource = """ // diff --git a/Fluid.Tests/MemberAccessStrategyTests.cs b/Fluid.Tests/MemberAccessStrategyTests.cs index ef1471c4..4ca1ec12 100644 --- a/Fluid.Tests/MemberAccessStrategyTests.cs +++ b/Fluid.Tests/MemberAccessStrategyTests.cs @@ -50,7 +50,7 @@ public void ReflectedAccessorsShouldVaryByStringComparer() } [Fact] - public void WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() + public async Task WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() { var strategy = new DefaultMemberAccessStrategy(); var reflected = strategy.GetAccessor(typeof(Class1), nameof(Class1.Property1), StringComparer.Ordinal); @@ -63,22 +63,50 @@ public void WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() var accessor = strategy.GetAccessor(typeof(Class1), nameof(Class1.Property1), StringComparer.Ordinal); Assert.NotSame(reflected, accessor); - Assert.Null(accessor.Get(new Class1(), nameof(Class1.Property1), new TemplateContext())); + Assert.Null(await accessor.GetAsync(new Class1(), nameof(Class1.Property1), new TemplateContext())); } [Fact] - public void BaseRegistrationChangesShouldInvalidateDerivedAccessors() + public void RegistrationsShouldInvalidateCachedRenderedAccessors() + { + var options = new TemplateOptions + { + ModelNamesComparer = StringComparer.Ordinal + }; + var model = new Class1 { Property1 = "reflected" }; + var context = new TemplateContext(options).SetValue("item", model); + var template = _parser.Parse("{{ item.Property1 }}"); + + Assert.Equal("reflected", template.Render(context)); + + options.MemberAccessStrategy.Register( + typeof(Class1), + "*", + new FixedMemberAccessor("wildcard")); + + Assert.Equal("wildcard", template.Render(context)); + + options.MemberAccessStrategy.Register( + typeof(Class1), + nameof(Class1.Property1), + new FixedMemberAccessor("exact")); + + Assert.Equal("exact", template.Render(context)); + } + + [Fact] + public async Task BaseRegistrationChangesShouldInvalidateDerivedAccessors() { var strategy = new DefaultMemberAccessStrategy(); strategy.Register((instance, name) => "first"); var first = strategy.GetAccessor(typeof(DerivedClass1), "custom", StringComparer.Ordinal); - Assert.Equal("first", first.Get(new DerivedClass1(), "custom", new TemplateContext())); + Assert.Equal("first", (await first.GetAsync(new DerivedClass1(), "custom", new TemplateContext())).ToObjectValue()); strategy.Register((instance, name) => "second"); var second = strategy.GetAccessor(typeof(DerivedClass1), "custom", StringComparer.Ordinal); - Assert.Equal("second", second.Get(new DerivedClass1(), "custom", new TemplateContext())); + Assert.Equal("second", (await second.GetAsync(new DerivedClass1(), "custom", new TemplateContext())).ToObjectValue()); } [Fact] @@ -404,7 +432,7 @@ void ITemplateOptionsMemberAccessorRegistrar.RegisterMemberAccessors(TemplateOpt } } - public sealed class FixedMemberAccessor : IMemberAccessor + public sealed class FixedMemberAccessor : MemberAccessor { private readonly string _value; @@ -413,6 +441,7 @@ public FixedMemberAccessor(string value) _value = value; } - public object Get(object obj, string name, TemplateContext ctx) => _value; + public override ValueTask GetAsync(object obj, string name, TemplateContext context) + => CreateValueTask(_value, context); } } diff --git a/Fluid.Tests/MemberAccessorGeneratorTests.cs b/Fluid.Tests/MemberAccessorGeneratorTests.cs index 6e3d2643..aecd285e 100644 --- a/Fluid.Tests/MemberAccessorGeneratorTests.cs +++ b/Fluid.Tests/MemberAccessorGeneratorTests.cs @@ -36,13 +36,12 @@ public static partial class FluidProfiles Assert.Contains("internal sealed class FluidRegisterAttribute", generated); Assert.Contains("public static partial void ApplyPublic(global::Fluid.TemplateOptions options)", generated); Assert.Contains("strategy.Register(typeof(global::Person), \"*\", new global::Fluid.SourceGenerated.Person_GeneratedMemberAccessor());", generated); + Assert.Contains(": global::Fluid.MemberAccessor", generated); Assert.Contains("comparer.Equals(name, \"FirstName\")", generated); Assert.Contains("comparer.Equals(name, \"Age\")", generated); - Assert.Contains("var task = typed.Loaded();", generated); - Assert.Contains("await task.ConfigureAwait(false);", generated); - Assert.Contains("var valueTask = typed.Initialized();", generated); - Assert.Contains("await valueTask.ConfigureAwait(false);", generated); - Assert.Contains("return null;", generated); + Assert.Contains("return CreateValueTask(typed.Loaded(), context);", generated); + Assert.Contains("return CreateValueTask(typed.Initialized(), context);", generated); + Assert.Contains("return default;", generated); } [Fact] diff --git a/Fluid.Tests/RenderCacheInvalidationTests.cs b/Fluid.Tests/RenderCacheInvalidationTests.cs index 69f0f24c..5a2a8ea4 100644 --- a/Fluid.Tests/RenderCacheInvalidationTests.cs +++ b/Fluid.Tests/RenderCacheInvalidationTests.cs @@ -89,7 +89,7 @@ private sealed class ToggleStrategy : DefaultMemberAccessStrategy public int GetAccessorCalls; - public override IMemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) + public override MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) { GetAccessorCalls++; diff --git a/Fluid/Accessors/AccessorValueConverter.cs b/Fluid/Accessors/AccessorValueConverter.cs index dac87b6c..9e4d7212 100644 --- a/Fluid/Accessors/AccessorValueConverter.cs +++ b/Fluid/Accessors/AccessorValueConverter.cs @@ -4,11 +4,16 @@ namespace Fluid.Accessors; internal static class AccessorValueConverter { - public static object Convert(object value, TypeCode typeCode, bool isEnum) + public static FluidValue Convert(object value, TypeCode typeCode, bool isEnum, TemplateOptions options) { - if (value == null || isEnum) + if (value == null) { - return value; + return null; + } + + if (isEnum) + { + return FluidValue.Create(value, options); } return typeCode switch @@ -27,7 +32,7 @@ public static object Convert(object value, TypeCode typeCode, bool isEnum) TypeCode.Decimal => NumberValue.Create((decimal)value), TypeCode.DateTime => new DateTimeValue((DateTime)value), TypeCode.String => StringValue.Create((string)value), - _ => value, + _ => FluidValue.Create(value, options), }; } } diff --git a/Fluid/Accessors/AsyncDelegateAccessorOfT.cs b/Fluid/Accessors/AsyncDelegateAccessorOfT.cs index 28863a38..d15cd980 100644 --- a/Fluid/Accessors/AsyncDelegateAccessorOfT.cs +++ b/Fluid/Accessors/AsyncDelegateAccessorOfT.cs @@ -1,6 +1,8 @@ -namespace Fluid.Accessors +using Fluid.Values; + +namespace Fluid.Accessors { - public class AsyncDelegateAccessor : IAsyncMemberAccessor + public class AsyncDelegateAccessor : MemberAccessor { private readonly Func> _getter; @@ -9,19 +11,14 @@ public AsyncDelegateAccessor(Func> get _getter = getter; } - public object Get(object obj, string name, TemplateContext ctx) - { - throw new NotImplementedException(); - } - public Task GetAsync(T obj, string name, TemplateContext ctx) { return _getter(obj, name, ctx); } - async Task IAsyncMemberAccessor.GetAsync(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return await _getter((T)obj, name, ctx); + return CreateValueTask(_getter((T)obj, name, context), context); } } } diff --git a/Fluid/Accessors/DelegateAccessorOfT.cs b/Fluid/Accessors/DelegateAccessorOfT.cs index 0047a58e..3a096f22 100644 --- a/Fluid/Accessors/DelegateAccessorOfT.cs +++ b/Fluid/Accessors/DelegateAccessorOfT.cs @@ -1,6 +1,8 @@ -namespace Fluid.Accessors +using Fluid.Values; + +namespace Fluid.Accessors { - public class DelegateAccessor : IMemberAccessor + public class DelegateAccessor : MemberAccessor { private readonly Func _getter; @@ -9,9 +11,9 @@ public DelegateAccessor(Func getter) _getter = getter; } - object IMemberAccessor.Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return _getter((T)obj, name, ctx); + return CreateValueTask(_getter((T)obj, name, context), context); } } } diff --git a/Fluid/Accessors/FieldInfoAccessor.cs b/Fluid/Accessors/FieldInfoAccessor.cs index 96ee8377..d2b568c7 100644 --- a/Fluid/Accessors/FieldInfoAccessor.cs +++ b/Fluid/Accessors/FieldInfoAccessor.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors { - public sealed class FieldInfoAccessor : IMemberAccessor + public sealed class FieldInfoAccessor : MemberAccessor { private readonly Invoker _invoker; @@ -48,10 +48,8 @@ public FieldInfoAccessor(FieldInfo fieldInfo) _invoker = (Invoker) Activator.CreateInstance(invokerType, [d, converter]); } - public object Get(object obj, string name, TemplateContext ctx) - { - return _invoker?.Invoke(obj, ctx.Options); - } + public override ValueTask GetAsync(object obj, string name, TemplateContext context) + => new(_invoker?.Invoke(obj, context.Options)); private static Delegate GetGetter(FieldInfo field) { diff --git a/Fluid/Accessors/Invoker.cs b/Fluid/Accessors/Invoker.cs index 31fd6750..30db7bcd 100644 --- a/Fluid/Accessors/Invoker.cs +++ b/Fluid/Accessors/Invoker.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors; internal abstract class Invoker { - public abstract object Invoke(object target, TemplateOptions options); + public abstract FluidValue Invoke(object target, TemplateOptions options); } internal sealed class Invoker : Invoker @@ -18,9 +18,13 @@ public Invoker(Delegate d, Func converter) _converter = converter; } - public override object Invoke(object target, TemplateOptions options) + public override FluidValue Invoke(object target, TemplateOptions options) { var result = _d((T)target); - return _converter != null ? _converter(result, options) : result; + return result is null + ? null + : _converter != null + ? _converter(result, options) + : FluidValue.Create(result, options); } } diff --git a/Fluid/Accessors/MethodInfoAccessor.cs b/Fluid/Accessors/MethodInfoAccessor.cs index 723c1657..3f0085cc 100644 --- a/Fluid/Accessors/MethodInfoAccessor.cs +++ b/Fluid/Accessors/MethodInfoAccessor.cs @@ -1,8 +1,9 @@ using System.Reflection; +using Fluid.Values; namespace Fluid.Accessors { - public sealed class MethodInfoAccessor : IMemberAccessor + public sealed class MethodInfoAccessor : MemberAccessor { private readonly MethodInfo _methodInfo; @@ -11,9 +12,9 @@ public MethodInfoAccessor(MethodInfo methodInfo) _methodInfo = methodInfo; } - public object Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return _methodInfo.Invoke(obj, null); + return CreateValueTask(_methodInfo.Invoke(obj, null), context); } } diff --git a/Fluid/Accessors/PropertyInfoAccessor.cs b/Fluid/Accessors/PropertyInfoAccessor.cs index 8d34a122..74dceb03 100644 --- a/Fluid/Accessors/PropertyInfoAccessor.cs +++ b/Fluid/Accessors/PropertyInfoAccessor.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors; -public sealed class PropertyInfoAccessor : IMemberAccessor +public sealed class PropertyInfoAccessor : MemberAccessor { private readonly Invoker _invoker; @@ -65,7 +65,8 @@ public PropertyInfoAccessor(PropertyInfo propertyInfo) _invoker = (Invoker)Activator.CreateInstance(invokerType, [d, converter]); } - public object Get(object obj, string name, TemplateContext ctx) => _invoker.Invoke(obj, ctx.Options); + public override ValueTask GetAsync(object obj, string name, TemplateContext context) + => new(_invoker.Invoke(obj, context.Options)); private static Delegate GetGetter(Type declaringType, string fieldName) { diff --git a/Fluid/Accessors/ReflectionFieldInfoAccessor.cs b/Fluid/Accessors/ReflectionFieldInfoAccessor.cs index 82c55d3c..29ff0a71 100644 --- a/Fluid/Accessors/ReflectionFieldInfoAccessor.cs +++ b/Fluid/Accessors/ReflectionFieldInfoAccessor.cs @@ -1,8 +1,9 @@ +using Fluid.Values; using System.Reflection; namespace Fluid.Accessors; -internal sealed class ReflectionFieldInfoAccessor : IMemberAccessor +internal sealed class ReflectionFieldInfoAccessor : MemberAccessor { private readonly FieldInfo _fieldInfo; private readonly TypeCode _typeCode; @@ -15,8 +16,12 @@ public ReflectionFieldInfoAccessor(FieldInfo fieldInfo) _isEnum = fieldInfo.FieldType.IsEnum; } - public object Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return AccessorValueConverter.Convert(_fieldInfo.GetValue(obj), _typeCode, _isEnum); + return new(AccessorValueConverter.Convert( + _fieldInfo.GetValue(obj), + _typeCode, + _isEnum, + context.Options)); } } diff --git a/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs b/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs index 81357257..705caef9 100644 --- a/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs +++ b/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs @@ -1,8 +1,9 @@ +using Fluid.Values; using System.Reflection; namespace Fluid.Accessors; -internal sealed class ReflectionPropertyInfoAccessor : IMemberAccessor +internal sealed class ReflectionPropertyInfoAccessor : MemberAccessor { private readonly PropertyInfo _propertyInfo; private readonly TypeCode _typeCode; @@ -15,8 +16,12 @@ public ReflectionPropertyInfoAccessor(PropertyInfo propertyInfo) _isEnum = propertyInfo.PropertyType.IsEnum; } - public object Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return AccessorValueConverter.Convert(_propertyInfo.GetValue(obj), _typeCode, _isEnum); + return new(AccessorValueConverter.Convert( + _propertyInfo.GetValue(obj), + _typeCode, + _isEnum, + context.Options)); } } diff --git a/Fluid/DefaultMemberAccessStrategy.cs b/Fluid/DefaultMemberAccessStrategy.cs index d6e498f7..eaf615f9 100644 --- a/Fluid/DefaultMemberAccessStrategy.cs +++ b/Fluid/DefaultMemberAccessStrategy.cs @@ -17,12 +17,12 @@ public ReflectionCache(object registrationToken) } public object RegistrationToken { get; } - public volatile Dictionary Accessors = []; + public volatile Dictionary Accessors = []; } private static readonly bool _dynamicCodeSupported = IsDynamicCodeSupported(); - private volatile Dictionary _registrations = []; + private volatile Dictionary _registrations = []; private volatile ReflectionCache _reflectionCache; // Only the exact type opts in. A derived strategy may override GetAccessor to resolve from its @@ -38,7 +38,7 @@ public DefaultMemberAccessStrategy() protected internal override object AccessorCacheToken => _accessorCachingSupported ? _registrations : null; - public override IMemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) + public override MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(name); @@ -69,16 +69,16 @@ public override IMemberAccessor GetAccessor(Type type, string name, StringCompar [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryGetRegisteredAccessor( - Dictionary registrations, + Dictionary registrations, Type type, string name, - out IMemberAccessor accessor) + out MemberAccessor accessor) { return registrations.TryGetValue(new AccessorKey(type, name), out accessor) || registrations.TryGetValue(new AccessorKey(type, "*"), out accessor); } - private static IMemberAccessor GetMemberAccessor(Type type, string name, StringComparer stringComparer) + private static MemberAccessor GetMemberAccessor(Type type, string name, StringComparer stringComparer) { foreach (var propertyInfo in type.GetTypeInfo().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { @@ -173,8 +173,8 @@ private static bool IsDynamicCodeSupported() } // Creates accessors based on base types and interfaces - private static IMemberAccessor GetAccessorUnlikely( - Dictionary registrations, + private static MemberAccessor GetAccessorUnlikely( + Dictionary registrations, Type type, string name, StringComparer stringComparer) @@ -214,7 +214,7 @@ private static IMemberAccessor GetAccessorUnlikely( return null; } - public override void Register(Type type, string name, IMemberAccessor accessor) + public override void Register(Type type, string name, MemberAccessor accessor) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(name); @@ -222,7 +222,7 @@ public override void Register(Type type, string name, IMemberAccessor accessor) while (true) { var registrations = _registrations; - var updated = new Dictionary(registrations) + var updated = new Dictionary(registrations) { [new AccessorKey(type, name)] = accessor }; @@ -260,7 +260,7 @@ private ReflectionCache GetReflectionCache(object registrationToken) private static void AddReflectedAccessor( ReflectionCache reflectionCache, ReflectedAccessorKey key, - IMemberAccessor accessor) + MemberAccessor accessor) { while (true) { @@ -271,7 +271,7 @@ private static void AddReflectedAccessor( return; } - var updated = new Dictionary(reflectedAccessors) + var updated = new Dictionary(reflectedAccessors) { [key] = accessor }; diff --git a/Fluid/IMemberAccessor.cs b/Fluid/IMemberAccessor.cs deleted file mode 100644 index bc1ec3f0..00000000 --- a/Fluid/IMemberAccessor.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Fluid -{ - public interface IMemberAccessor - { - object Get(object obj, string name, TemplateContext ctx); - } - - public interface IAsyncMemberAccessor : IMemberAccessor - { - Task GetAsync(object obj, string name, TemplateContext ctx); - } -} diff --git a/Fluid/MemberAccessStrategy.cs b/Fluid/MemberAccessStrategy.cs index 8e098fe0..75076ba2 100644 --- a/Fluid/MemberAccessStrategy.cs +++ b/Fluid/MemberAccessStrategy.cs @@ -2,13 +2,13 @@ namespace Fluid { public abstract class MemberAccessStrategy { - public abstract IMemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer); + public abstract MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer); - public abstract void Register(Type type, string name, IMemberAccessor accessor); + public abstract void Register(Type type, string name, MemberAccessor accessor); /// /// Gets a token identifying the current set of accessors this strategy would return, or - /// null to disable caching. Call sites may remember the + /// null to disable caching. Call sites may remember the /// resolved for a type and name, and re-resolve it only once this token changes. /// /// diff --git a/Fluid/MemberAccessStrategyExtensions.cs b/Fluid/MemberAccessStrategyExtensions.cs index 81c78524..c4153b1f 100644 --- a/Fluid/MemberAccessStrategyExtensions.cs +++ b/Fluid/MemberAccessStrategyExtensions.cs @@ -12,49 +12,49 @@ public static class MemberAccessStrategyExtensions #endif /// - /// Registers a named property when accessing a type using a + /// Registers a named property when accessing a type using a /// to retrieve the value. The name of the property doesn't have to exist on the object. /// /// The type to register. /// The . /// The name of the property to intercept. - /// The instance used to retrieve the value. + /// The instance used to retrieve the value. #if NET5_0_OR_GREATER - public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, string name, IMemberAccessor getter) + public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, string name, MemberAccessor getter) #else - public static void Register(this MemberAccessStrategy strategy, string name, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, string name, MemberAccessor getter) #endif { strategy.Register(typeof(T), name, getter); } /// - /// Registers a type using a to retrieve any of + /// Registers a type using a to retrieve any of /// its property values. /// /// The type to register. /// The . - /// The instance used to retrieve the value. + /// The instance used to retrieve the value. #if NET5_0_OR_GREATER - public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, IMemberAccessor getter) + public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, MemberAccessor getter) #else - public static void Register(this MemberAccessStrategy strategy, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, MemberAccessor getter) #endif { strategy.Register("*", getter); } /// - /// Registers a type using a to retrieve any of + /// Registers a type using a to retrieve any of /// its property values. /// /// The . /// The type to register. - /// The instance used to retrieve the value. + /// The instance used to retrieve the value. #if NET5_0_OR_GREATER - public static void Register(this MemberAccessStrategy strategy, [DynamicallyAccessedMembers(RegisteredMemberTypes)] Type type, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, [DynamicallyAccessedMembers(RegisteredMemberTypes)] Type type, MemberAccessor getter) #else - public static void Register(this MemberAccessStrategy strategy, Type type, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, Type type, MemberAccessor getter) #endif { strategy.Register(type, "*", getter); diff --git a/Fluid/MemberAccessor.cs b/Fluid/MemberAccessor.cs new file mode 100644 index 00000000..02247758 --- /dev/null +++ b/Fluid/MemberAccessor.cs @@ -0,0 +1,53 @@ +using Fluid.Values; + +namespace Fluid +{ + /// + /// Resolves a member to its Fluid value. A null result means the accessor did not handle the name. + /// + /// + /// Use the protected CreateValueTask overloads to convert CLR values and asynchronous + /// results with the value converters configured on the current template context. Return + /// for a Liquid nil value. + /// + public abstract class MemberAccessor + { + /// + /// Resolves a member on an object. + /// + /// The object that owns the member. + /// The member name requested by the template. + /// The current template context. + /// The resolved Fluid value, or null when the accessor did not handle the name. + public abstract ValueTask GetAsync(object obj, string name, TemplateContext context); + + protected static ValueTask CreateValueTask(T value, TemplateContext context) + { + return new(value is null ? null : FluidValue.Create(value, context.Options)); + } + + protected static async ValueTask CreateValueTask(Task task, TemplateContext context) + { + var value = await task.ConfigureAwait(false); + return value is null ? null : FluidValue.Create(value, context.Options); + } + + protected static async ValueTask CreateValueTask(ValueTask task, TemplateContext context) + { + var value = await task.ConfigureAwait(false); + return value is null ? null : FluidValue.Create(value, context.Options); + } + + protected static async ValueTask CreateValueTask(Task task, TemplateContext context) + { + await task.ConfigureAwait(false); + return null; + } + + protected static async ValueTask CreateValueTask(ValueTask task, TemplateContext context) + { + await task.ConfigureAwait(false); + return null; + } + } +} diff --git a/Fluid/NullMemberAccessor.cs b/Fluid/NullMemberAccessor.cs index 66eb68f0..255d1943 100644 --- a/Fluid/NullMemberAccessor.cs +++ b/Fluid/NullMemberAccessor.cs @@ -1,17 +1,19 @@ -namespace Fluid +using Fluid.Values; + +namespace Fluid { - public sealed class NullMemberAccessor : IMemberAccessor + public sealed class NullMemberAccessor : MemberAccessor { - public static readonly IMemberAccessor Instance = new NullMemberAccessor(); + public static readonly MemberAccessor Instance = new NullMemberAccessor(); private NullMemberAccessor() { } - object IMemberAccessor.Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return null; + return new((FluidValue)null); } } } diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index d13a547e..4483b620 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -39,7 +39,7 @@ public override bool Equals(FluidValue other) } /// - /// A resolved remembered by a single call site, so that repeatedly + /// A resolved remembered by a single call site, so that repeatedly /// reading the same member off the same type (a loop body, typically) doesn't hash the member /// name into the strategy's dictionary on every iteration. /// @@ -58,7 +58,7 @@ internal sealed class AccessorCacheEntry /// public static readonly AccessorCacheEntry Disabled = new(null, null, null, null, 0); - public AccessorCacheEntry(object token, Type type, StringComparer comparer, IMemberAccessor accessor, int misses) + public AccessorCacheEntry(object token, Type type, StringComparer comparer, MemberAccessor accessor, int misses) { Token = token; Type = type; @@ -70,7 +70,7 @@ public AccessorCacheEntry(object token, Type type, StringComparer comparer, IMem public readonly object Token; public readonly Type Type; public readonly StringComparer Comparer; - public readonly IMemberAccessor Accessor; + public readonly MemberAccessor Accessor; /// /// How often this site had to re-resolve. Counted on the entry rather than the segment so @@ -97,7 +97,7 @@ internal ValueTask GetValueAsync(string name, TemplateContext contex return GetValueAsync(name, context, nameHasDot, GetAccessorCached(name, context, ref cache)); } - private IMemberAccessor GetAccessorCached(string name, TemplateContext context, ref AccessorCacheEntry cache) + private MemberAccessor GetAccessorCached(string name, TemplateContext context, ref AccessorCacheEntry cache) { var type = Value.GetType(); var strategy = context.Options.MemberAccessStrategy; @@ -145,22 +145,24 @@ private IMemberAccessor GetAccessorCached(string name, TemplateContext context, return accessor; } - private ValueTask GetValueAsync(string name, TemplateContext context, bool nameHasDot, IMemberAccessor accessor) + private ValueTask GetValueAsync(string name, TemplateContext context, bool nameHasDot, MemberAccessor accessor) { if (nameHasDot) { if (accessor != null) { - if (accessor is IAsyncMemberAccessor asyncAccessor) + var task = accessor.GetAsync(Value, name, context); + + if (!task.IsCompletedSuccessfully) { - return Awaited(asyncAccessor, Value, name, context); + return AwaitedDirect(task, name, context); } - var directValue = accessor.Get(Value, name, context); + var directValue = task.Result; if (directValue != null) { - return FluidValue.Create(directValue, context.Options); + return new ValueTask(directValue); } } @@ -169,12 +171,13 @@ private ValueTask GetValueAsync(string name, TemplateContext context if (accessor != null) { - if (accessor is IAsyncMemberAccessor asyncAccessor) + var task = accessor.GetAsync(Value, name, context); + if (task.IsCompletedSuccessfully) { - return Awaited(asyncAccessor, Value, name, context); + return new ValueTask(task.Result ?? NilValue.Instance); } - return Create(accessor.Get(Value, name, context), context.Options); + return Awaited(task); } if (context.Options.StrictVariables) @@ -188,13 +191,18 @@ private ValueTask GetValueAsync(string name, TemplateContext context return NilValue.Instance; - static async ValueTask Awaited( - IAsyncMemberAccessor asyncAccessor, - object value, - string n, + static async ValueTask Awaited(ValueTask task) + { + return await task ?? NilValue.Instance; + } + + async ValueTask AwaitedDirect( + ValueTask task, + string memberName, TemplateContext ctx) { - return Create(await asyncAccessor.GetAsync(value, n, ctx), ctx.Options); + var directValue = await task; + return directValue ?? await GetNestedValueAsync(memberName, ctx); } } @@ -202,6 +210,7 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon { var members = name.Split(MemberSeparators); var target = Value; + FluidValue value = null; List segments = context.Undefined is not null ? [] : null; foreach (var prop in members) @@ -231,17 +240,11 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon return UndefinedValue.Instance; } - if (accessor is IAsyncMemberAccessor asyncAccessor) - { - target = await asyncAccessor.GetAsync(target, prop, context); - } - else - { - target = accessor.Get(target, prop, context); - } + value = await accessor.GetAsync(target, prop, context); + target = value?.ToObjectValue(); } - return Create(target, context.Options); + return value ?? NilValue.Instance; } public override ValueTask GetIndexAsync(FluidValue index, TemplateContext context) diff --git a/README.md b/README.md index b01ab4f1..bd33485b 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ context.SetValue("temporary", value); Fluid works when targeting NativeAOT and trimmed deployments. - If dynamic code is not supported at runtime, Fluid automatically switches to reflection-based member accessors. -- Existing `MemberAccessStrategy.Register` APIs are preserved. +- Runtime `MemberAccessStrategy.Register` APIs are available for custom mappings. - No interceptor setup is required. ### Recommended usage when targeting NativeAOT @@ -208,6 +208,29 @@ The generated registrations are instance-scoped and are applied automatically to options.MemberAccessStrategy.Register((product, name) => product.Name); ``` +### Custom member accessors + +Custom accessors derive from `MemberAccessor` and return a `ValueTask` directly. + +```csharp +private sealed class ProductDisplayNameAccessor : MemberAccessor +{ + public override ValueTask GetAsync( + object obj, + string name, + TemplateContext context) + { + return CreateValueTask(((Product)obj).Name, context); + } +} + +options.MemberAccessStrategy.Register( + "display_name", + new ProductDisplayNameAccessor()); +``` + +The protected `CreateValueTask` overloads convert synchronous, `Task`, and `ValueTask` results using the `TemplateOptions.ValueConverters` configured for the current context. Return `NilValue.Instance` for a Liquid `nil` value; a null `FluidValue` is reserved as the accessor's not-handled result. + Alternatively, explicit profile methods can apply generated registrations to any `TemplateOptions` instance: ```csharp