diff --git a/README.md b/README.md index d9bbd253..c9a61d35 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,42 @@ var result = template(data); // "6" This is a C#-specific capability, since JavaScript/Handlebars.js has no equivalent to true multi-dimensional arrays. +### System.Text.Json + +`System.Text.Json` values can be passed to a template directly, with no conversion to POCOs or dictionaries. + +**`JsonElement`**: deserializing JSON as `object` with `JsonSerializer.Deserialize(json)` gives you a `JsonElement`. You can pass that in, or any other `JsonElement` such as `JsonDocument.RootElement`. + +**`JsonNode`**: `JsonObject`, `JsonArray` and `JsonValue` work too, whether they come from `JsonNode.Parse` or are built in code. A node tree is read when the template renders, so changes made to it between renders show up in the output. + +```c# +var template = Handlebars.Compile("{{title}}: {{#each tags}}{{@index}}={{this}} {{/each}}{{#if draft}}(draft){{/if}}"); + +var json = @"{ ""title"": ""My post"", ""tags"": [""a"", ""b""], ""draft"": false }"; + +template(JsonSerializer.Deserialize(json)); // "My post: 0=a 1=b " +template(JsonNode.Parse(json)); // "My post: 0=a 1=b " + +var data = new JsonObject +{ + ["title"] = "My post", + ["tags"] = new JsonArray("a", "b"), + ["draft"] = true +}; + +template(data); // "My post: 0=a 1=b (draft)" +``` + +For both types: +- Properties are matched by exact name first, then by the lowercase name, so `{{Title}}` finds a `"title"` property. +- Arrays support index access (`{{tags.[0]}}`), `{{tags.Count}}`, and `#each` with `@index`, `@first` and `@last`. Objects support `#each` with `@key`. +- As with other collections, `{{tags.length}}` works once `handlebars.Configuration.UseCollectionMemberAliasProvider()` is enabled. +- `#if`, `#unless` and `#each` treat `false`, `null`, `0`, `""`, `[]` and `{}` as falsy. Pass `includeZero=true` to `#if` to treat `0` as truthy. + +When interpolated directly, booleans render as `True`/`False` for `JsonElement` and `true`/`false` for `JsonNode`. + +Compatibility: `JsonNode` values `false`, `0`, and `""` now follow falsy semantics; `JsonObject` lookups now include the lowercase fallback described above; custom member alias providers are now consulted for unresolved `JsonElement` and `JsonObject` members. + ### Registering Partials ```c# diff --git a/source/Handlebars.Test/DynamicTests.cs b/source/Handlebars.Test/DynamicTests.cs index cc3ce01e..aed6c110 100644 --- a/source/Handlebars.Test/DynamicTests.cs +++ b/source/Handlebars.Test/DynamicTests.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text.Json; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -301,116 +300,6 @@ public void WithParentIndexJsonNet(IHandlebars handlebars) Assert.Equal( makeFlat( expected ), makeFlat( result ) ); } - // System.Text.Json's JsonElement (the result of System.Text.Json.JsonSerializer.Deserialize) has no - // built-in support for member access/iteration in .NET, unlike Newtonsoft's JObject/JToken - // above. These tests mirror the JObject coverage to ensure feature parity between the two. - [Fact] - public void JsonElementResolvesNestedProperty() - { - var json = "{\"A\": {\"B\": \"b\"}}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{A.B}}"); - - Assert.Equal("b", template(data)); - } - - [Fact] - public void JsonElementRendersScalarPropertyValues() - { - var json = "{\"str\": \"hello\", \"num\": 42, \"nothing\": null}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{str}}|{{num}}|{{nothing}}"); - - Assert.Equal("hello|42|", template(data)); - } - - [Fact] - public void JsonElementIteratesArray() - { - var json = "{\"items\": [1, 2, 3]}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{#each items}}{{this}},{{/each}}"); - - Assert.Equal("1,2,3,", template(data)); - } - - [Fact] - public void JsonElementIteratesArrayOfObjects() - { - var json = "{\"items\": [{\"Name\": \"a\"}, {\"Name\": \"b\"}]}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{#each items}}{{Name}},{{/each}}"); - - Assert.Equal("a,b,", template(data)); - } - - [Fact] - public void JsonElementIteratesObjectProperties() - { - var json = "{\"A\": \"1\", \"B\": \"2\"}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{#each this}}{{@key}}={{this}} {{/each}}"); - - Assert.Equal("A=1 B=2 ", template(data)); - } - - [Theory] - [InlineData("{\"flag\": true}", "yes")] - [InlineData("{\"flag\": false}", "no")] - public void JsonElementBooleanRespectsTruthiness(string json, string expected) - { - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{#if flag}}yes{{else}}no{{/if}}"); - - Assert.Equal(expected, template(data)); - } - - [Fact] - public void JsonElementTreatsEmptyStringNullAndZeroAsFalsy() - { - var json = "{\"empty\": \"\", \"nothing\": null, \"zero\": 0}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile( - "{{#if empty}}T{{else}}F{{/if}}" + - "{{#if nothing}}T{{else}}F{{/if}}" + - "{{#if zero}}T{{else}}F{{/if}}" - ); - - Assert.Equal("FFF", template(data)); - } - - [Fact] - public void JsonElementTreatsEmptyArrayAndObjectAsFalsy() - { - var json = "{\"emptyArr\": [], \"emptyObj\": {}}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile( - "{{#if emptyArr}}T{{else}}F{{/if}}" + - "{{#if emptyObj}}T{{else}}F{{/if}}" - ); - - Assert.Equal("FF", template(data)); - } - - [Fact] - public void JsonElementReturnsEmptyForMissingProperty() - { - var json = "{\"A\": \"a\"}"; - object data = System.Text.Json.JsonSerializer.Deserialize(json)!; - - var template = Handlebars.Create().Compile("{{Missing.Nested}}"); - - Assert.Equal("", template(data)); - } - #if NET452 || NET46 || NET461 || NET472 [Fact] @@ -449,4 +338,3 @@ public override bool TryGetMember(GetMemberBinder binder, out object result) } } } - diff --git a/source/Handlebars.Test/JsonTests.cs b/source/Handlebars.Test/JsonTests.cs new file mode 100644 index 00000000..d993cc28 --- /dev/null +++ b/source/Handlebars.Test/JsonTests.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using HandlebarsDotNet.Features; +using HandlebarsDotNet.ObjectDescriptors; +using Xunit; + +namespace HandlebarsDotNet.Test +{ + public abstract class JsonTestBase + { + protected abstract object Parse(string json); + + [Fact] + public void ResolvesNestedProperty() + { + var template = Handlebars.Create().Compile("{{A.B}}"); + + Assert.Equal("b", template(Parse("{\"A\": {\"B\": \"b\"}}"))); + } + + [Fact] + public void RendersScalarPropertyValues() + { + var template = Handlebars.Create().Compile("{{str}}|{{num}}|{{nothing}}"); + + Assert.Equal("hello|42|", template(Parse("{\"str\": \"hello\", \"num\": 42, \"nothing\": null}"))); + } + + [Fact] + public void IteratesArray() + { + var template = Handlebars.Create().Compile("{{#each items}}{{this}},{{/each}}"); + + Assert.Equal("1,2,3,", template(Parse("{\"items\": [1, 2, 3]}"))); + } + + [Theory] + [InlineData("{{#each this}}{{Name}},{{/each}}", "[{\"Name\": \"a\"}, {\"Name\": \"b\"}]")] + [InlineData("{{#each items}}{{Name}},{{/each}}", "{\"items\": [{\"Name\": \"a\"}, {\"Name\": \"b\"}]}")] + public void IteratesArrayOfObjects(string source, string json) + { + var template = Handlebars.Create().Compile(source); + + Assert.Equal("a,b,", template(Parse(json))); + } + + [Fact] + public void IteratesObjectProperties() + { + var template = Handlebars.Create().Compile("{{#each this}}{{@key}}={{this}} {{/each}}"); + + Assert.Equal("A=1 B=2 ", template(Parse("{\"A\": \"1\", \"B\": \"2\"}"))); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("null", false)] + [InlineData("\"\"", false)] + [InlineData("\"text\"", true)] + [InlineData("\"false\"", true)] + [InlineData("0", false)] + [InlineData("0.0", false)] + [InlineData("1", true)] + [InlineData("-1.5", true)] + [InlineData("[]", false)] + [InlineData("{}", false)] + [InlineData("[null]", true)] + [InlineData("{\"key\": null}", true)] + public void RespectsConditionalTruthiness(string value, bool truthy) + { + var template = Handlebars.Create().Compile("{{#if value}}T{{else}}F{{/if}}|{{#unless value}}F{{else}}T{{/unless}}"); + + Assert.Equal(truthy ? "T|T" : "F|F", template(Parse("{\"value\": " + value + "}"))); + } + + [Theory] + [InlineData("true", "T")] + [InlineData("false", "F")] + [InlineData("null", "F")] + [InlineData("0", "F")] + [InlineData("\"\"", "F")] + [InlineData("\"text\"", "T")] + [InlineData("[]", "F")] + [InlineData("{}", "F")] + public void RespectsRootValueTruthiness(string json, string expected) + { + var template = Handlebars.Create().Compile("{{#if this}}T{{else}}F{{/if}}"); + + Assert.Equal(expected, template(Parse(json))); + } + + [Theory] + [InlineData("0", "F|T")] + [InlineData("0.0", "F|T")] + [InlineData("1", "T|T")] + public void HonorsIncludeZero(string value, string expected) + { + var template = Handlebars.Create().Compile("{{#if value includeZero=false}}T{{else}}F{{/if}}|{{#if value includeZero=true}}T{{else}}F{{/if}}"); + + Assert.Equal(expected, template(Parse("{\"value\": " + value + "}"))); + } + + [Fact] + public void ReturnsEmptyForMissingProperty() + { + var template = Handlebars.Create().Compile("{{Missing.Nested}}|{{#if Missing}}T{{else}}F{{/if}}"); + + Assert.Equal("|F", template(Parse("{\"A\": \"a\"}"))); + } + + [Fact] + public void ReturnsEmptyForNullAndScalarMemberAccess() + { + var template = Handlebars.Create().Compile("{{nullVal.Nested}}|{{intVal.Nested}}|{{stringVal.Nested}}"); + + Assert.Equal("||", template(Parse("{\"nullVal\": null, \"intVal\": 1, \"stringVal\": \"a\"}"))); + } + + [Theory] + [InlineData("{{Prop}}|{{prop}}", "{\"Prop\": \"a\", \"prop\": \"b\"}", "a|b")] + [InlineData("{{PROP}}", "{\"prop\": \"a\"}", "a")] + public void PrefersExactPropertyNameBeforeLowercaseFallback(string source, string json, string expected) + { + var template = Handlebars.Create().Compile(source); + + Assert.Equal(expected, template(Parse(json))); + } + + [Theory] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + [InlineData("42")] + [InlineData("true")] + [InlineData("\"text\"")] + public void EachUsesInverseForEmptyOrScalarValues(string json) + { + var template = Handlebars.Create().Compile("{{#each this}}item{{else}}empty{{/each}}"); + + Assert.Equal("empty", template(Parse(json))); + } + + [Fact] + public void IteratedValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#each items}}{{#if this}}T{{else}}F{{/if}},{{/each}}"); + + Assert.Equal("F,T,F,T,F,T,F,F,F,T,", template(Parse("{\"items\": [false, true, 0, 1, \"\", \"false\", null, [], {}, [1]]}"))); + } + + [Fact] + public void PreservesArrayIterationMetadataAndBlockParameters() + { + var template = Handlebars.Create().Compile("{{#each items as |item itemIndex|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{itemIndex}}:{{item}}:{{@value}};{{/each}}"); + + Assert.Equal("0:0:True:False:0:a:a;1:1:False:True:1:b:b;", template(Parse("{\"items\": [\"a\", \"b\"]}"))); + } + + [Fact] + public void PreservesObjectIterationMetadataAndBlockParameters() + { + var template = Handlebars.Create().Compile("{{#each this as |item propertyKey|}}{{@index}}:{{@key}}:{{@first}}:{{@last}}:{{propertyKey}}:{{item}}:{{@value}};{{/each}}"); + + Assert.Equal("0:A:True:False:A:a:a;1:B:False:True:B:b:b;", template(Parse("{\"A\": \"a\", \"B\": \"b\"}"))); + } + + [Fact] + public void PreservesParentAndRootContextInNestedLoops() + { + var template = Handlebars.Create().Compile("{{#each groups as |group|}}{{#each items as |item itemIndex|}}{{@../index}}.{{itemIndex}}={{../stringVal}}/{{group.stringVal}}/{{item.stringVal}}/{{@root.stringVal}};{{/each}}{{/each}}"); + var data = Parse("{\"stringVal\": \"root\", \"groups\": [{\"stringVal\": \"a\", \"items\": [{\"stringVal\": \"x\"}, {\"stringVal\": \"y\"}]}, {\"stringVal\": \"b\", \"items\": [{\"stringVal\": \"z\"}]}]}"); + + Assert.Equal("0.0=a/a/x/root;0.1=a/a/y/root;1.0=b/b/z/root;", template(data)); + } + + [Fact] + public void WithPreservesParentAndRootContext() + { + var template = Handlebars.Create().Compile("{{#with objectVal}}{{stringVal}}:{{../rootVal}}:{{@root.rootVal}}{{/with}}"); + + Assert.Equal("b:a:a", template(Parse("{\"rootVal\": \"a\", \"objectVal\": {\"stringVal\": \"b\"}}"))); + } + + [Theory] + [InlineData("{{arrayVal.[0].stringVal}}|{{arrayVal.[1].stringVal}}", "a|b")] + [InlineData("{{arrayVal.[2].stringVal}}|{{arrayVal.[-1].stringVal}}|{{arrayVal.[x].stringVal}}", "||")] + [InlineData("{{scalars.[1]}}|{{lookup scalars 1}}|{{lookup scalars 5}}", "y|y|")] + [InlineData("{{matrix.[1].[0]}}", "3")] + [InlineData("{{#with arrayVal.[1]}}{{stringVal}}{{/with}}", "b")] + [InlineData("{{#if scalars.[0]}}T{{else}}F{{/if}}{{#if scalars.[3]}}T{{else}}F{{/if}}", "TF")] + public void ResolvesArrayIndexes(string source, string expected) + { + var template = Handlebars.Create().Compile(source); + var data = Parse("{\"arrayVal\": [{\"stringVal\": \"a\"}, {\"stringVal\": \"b\"}], \"scalars\": [\"x\", \"y\"], \"matrix\": [[1, 2], [3, 4]]}"); + + Assert.Equal(expected, template(data)); + } + + [Fact] + public void PartialHashMergesContextProperties() + { + var handlebars = Handlebars.Create(); + handlebars.RegisterTemplate("partial", "{{stringVal}}/{{extra}}"); + var template = handlebars.Compile("{{#with objectVal}}{{> partial extra='x'}}{{/with}}"); + + Assert.Equal("b/x", template(Parse("{\"objectVal\": {\"stringVal\": \"b\"}}"))); + } + + [Fact] + public void PreservesArrayIndexAndCountAccess() + { + var template = Handlebars.Create().Compile("{{arrayVal.[0].stringVal}}|{{arrayVal.[1].stringVal}}|{{arrayVal.Count}}|{{arrayVal.[2].stringVal}}|{{arrayVal.[-1].stringVal}}|{{emptyVal.Count}}"); + + Assert.Equal("a|b|2|||0", template(Parse("{\"arrayVal\": [{\"stringVal\": \"a\"}, {\"stringVal\": \"b\"}], \"emptyVal\": []}"))); + } + + [Theory] + [InlineData("Parent", false)] + [InlineData("parent", false)] + [InlineData("PARENT", false)] + [InlineData("Root", false)] + [InlineData("root", false)] + [InlineData("ROOT", false)] + [InlineData("Parent", true)] + [InlineData("parent", true)] + [InlineData("PARENT", true)] + [InlineData("Root", true)] + [InlineData("root", true)] + [InlineData("ROOT", true)] + public void ArrayMembersDoNotExposeNodeParents(string member, bool useAliases) + { + var handlebars = Handlebars.Create(); + if (useAliases) + { + handlebars.Configuration.UseCollectionMemberAliasProvider(); + } + + var template = handlebars.Compile("{{holder.items." + member + "}}|{{holder.items." + member + ".secret}}|{{lookup holder.items '" + member + "'}}|{{lookup (lookup holder.items '" + member + "') 'secret'}}"); + var data = Parse("{\"secret\": \"root\", \"holder\": {\"secret\": \"parent\", \"items\": [1]}}"); + + Assert.Equal("|||", template(data)); + } + + [Fact] + public void ArrayElementsKeepJsonPropertiesNamedLikeNodeMetadata() + { + var template = Handlebars.Create().Compile("{{items.[0].Parent}}|{{items.[0].Root}}|{{items.[0].Options}}|{{#each items}}{{this.Parent}}/{{this.Root}}/{{this.Options}}/{{lookup this 'Parent'}}/{{lookup this 'Root'}}/{{lookup this 'Options'}};{{/each}}"); + var data = Parse("{\"items\": [{\"Parent\": \"p\", \"Root\": \"r\", \"Options\": \"o\"}]}"); + + Assert.Equal("p|r|o|p/r/o/p/r/o;", template(data)); + } + + [Theory] + [InlineData(false, "2|2||")] + [InlineData(true, "2|2|2|2")] + public void ArrayMembersUseConfiguredCollectionAliases(bool useAliases, string expected) + { + var handlebars = Handlebars.Create(); + if (useAliases) + { + handlebars.Configuration.UseCollectionMemberAliasProvider(); + } + + var template = handlebars.Compile("{{arrayVal.Count}}|{{arrayVal.count}}|{{arrayVal.Length}}|{{arrayVal.length}}"); + var data = Parse("{\"arrayVal\": [\"a\", \"b\"]}"); + + Assert.Equal(expected, template(data)); + Assert.Equal(template(new { arrayVal = new List { "a", "b" } }), template(data)); + } + + [Fact] + public void ObjectMembersKeepJsonPropertyLookupWithCollectionAliases() + { + var handlebars = Handlebars.Create(); + handlebars.Configuration.UseCollectionMemberAliasProvider(); + var template = handlebars.Compile("{{objectVal.Count}}|{{objectVal.Length}}|{{objectVal.Options}}|{{emptyObj.Count}}|{{emptyObj.length}}|{{stringVal.Count}}|{{stringVal.length}}"); + + Assert.Equal("7|8|||||", template(Parse("{\"objectVal\": {\"Count\": 7, \"Length\": 8}, \"emptyObj\": {}, \"stringVal\": \"abc\"}"))); + } + + [Fact] + public void ObjectMembersUseCustomAliases() + { + var data = Parse("{}"); + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("genericAlias", _ => "generic")); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("baseAlias", _ => "base").AddAlias("baseAlias", _ => "base")); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("typedAlias", _ => "typed").AddAlias("typedAlias", _ => "typed")); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias(data.GetType(), "runtimeAlias", _ => "runtime")); + var template = handlebars.Compile("{{genericAlias}}|{{baseAlias}}|{{typedAlias}}|{{runtimeAlias}}"); + + Assert.Equal("generic|base|typed|runtime", template(data)); + } + + [Fact] + public void JsonPropertiesTakePrecedenceOverCustomAliases() + { + var data = Parse("{\"Name\": \"exact\", \"lower\": \"fallback\", \"nothing\": null}"); + var aliases = new DelegatedMemberAliasProvider() + .AddAlias(data.GetType(), "Name", _ => "alias") + .AddAlias(data.GetType(), "LOWER", _ => "alias") + .AddAlias(data.GetType(), "nothing", _ => "alias"); + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(aliases); + var template = handlebars.Compile("{{Name}}|{{LOWER}}|{{nothing}}"); + + Assert.Equal("exact|fallback|", template(data)); + } + } + + public class JsonElementTests : JsonTestBase + { + protected override object Parse(string json) => JsonSerializer.Deserialize(json)!; + + [Fact] + public void BooleanTextKeepsExistingFormatting() + { + var template = Handlebars.Create().Compile("{{falseVal}}|{{trueVal}}"); + + Assert.Equal("False|True", template(Parse("{\"falseVal\": false, \"trueVal\": true}"))); + } + + [Fact] + public void ArrayMembersUseCustomAliases() + { + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("itemCount", element => element.GetArrayLength())); + var template = handlebars.Compile("{{arrayVal.itemCount}}|{{arrayVal.[0]}}"); + + Assert.Equal("2|a", template(Parse("{\"arrayVal\": [\"a\", \"b\"]}"))); + } + } + + public class JsonNodeTests : JsonTestBase + { + protected override object Parse(string json) => JsonNode.Parse(json)!; + + [Fact] + public void BooleanTextKeepsExistingFormatting() + { + var template = Handlebars.Create().Compile("{{falseVal}}|{{trueVal}}"); + + Assert.Equal("false|true", template(Parse("{\"falseVal\": false, \"trueVal\": true}"))); + } + + [Fact] + public void CreatedNumericValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#if value}}T{{else}}F{{/if}}|{{#if value includeZero=true}}T{{else}}F{{/if}}"); + var zeros = new JsonNode[] + { + JsonValue.Create(0)!, JsonValue.Create(0L)!, JsonValue.Create(0d)!, JsonValue.Create(0f)!, + JsonValue.Create(0m)!, JsonValue.Create(0u)!, JsonValue.Create(0UL)!, JsonValue.Create((short) 0)!, + JsonValue.Create((ushort) 0)!, JsonValue.Create((byte) 0)!, JsonValue.Create((sbyte) 0)! + }; + + foreach (var zero in zeros) + { + Assert.Equal("F|T", template(new JsonObject { ["value"] = zero })); + } + } + + [Fact] + public void CreatedNodesRenderAndReflectChanges() + { + var data = new JsonObject + { + ["stringVal"] = "a", + ["boolVal"] = false, + ["arrayVal"] = new JsonArray(new JsonObject { ["intVal"] = 1 }) + }; + var template = Handlebars.Create().Compile("{{stringVal}}:{{#if boolVal}}T{{else}}F{{/if}}:{{#each arrayVal}}{{intVal}}{{/each}}"); + + Assert.Equal("a:F:1", template(data)); + + data["stringVal"] = "b"; + data["boolVal"] = true; + data["arrayVal"]![0]!["intVal"] = 2; + + Assert.Equal("b:T:2", template(data)); + } + + [Fact] + public void CreatedTextValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#if this}}T{{else}}F{{/if}}"); + + Assert.Equal("F", template(JsonValue.Create(string.Empty))); + Assert.Equal("T", template(JsonValue.Create('a'))); + Assert.Equal("T", template(JsonValue.Create(Guid.Empty))); + Assert.Equal("T", template(JsonValue.Create(DateTime.MinValue))); + } + + [Fact] + public void CreatedBooleanValuesRespectTruthiness() + { + var template = Handlebars.Create().Compile("{{#if this}}T{{else}}F{{/if}}"); + + Assert.Equal("F", template(JsonValue.Create(false))); + Assert.Equal("T", template(JsonValue.Create(true))); + } + + [Theory] + [InlineData("false", "F|F")] + [InlineData("true", "T|T")] + [InlineData("0", "F|T")] + [InlineData("0.0", "F|T")] + [InlineData("1", "T|T")] + [InlineData("\"\"", "F|F")] + [InlineData("\"false\"", "T|T")] + public void CreatedJsonElementValuesRespectTruthiness(string json, string expected) + { + using var document = JsonDocument.Parse(json); + var value = JsonValue.Create(document.RootElement); + var template = Handlebars.Create().Compile("{{#if value}}T{{else}}F{{/if}}|{{#if value includeZero=true}}T{{else}}F{{/if}}"); + + Assert.Equal(expected, template(new JsonObject { ["value"] = value })); + } + + [Fact] + public void WrappedClrValuesRemainOpaqueWithoutSerialization() + { + var handlebars = Handlebars.Create(); + var nested = handlebars.Compile("{{items.Name}}|{{items.Self}}|{{items.Count}}|{{items.[0]}}|{{lookup items 'Name'}}|{{#each items}}item{{else}}empty{{/each}}|{{#if items}}T{{else}}F{{/if}}"); + var root = handlebars.Compile("{{Name}}|{{Self}}|{{Count}}|{{this.[0]}}|{{lookup this 'Name'}}|{{#each this}}item{{else}}empty{{/each}}|{{#if this}}T{{else}}F{{/if}}"); + var values = new object[] + { + new[] { 1, 2 }, new List { "a", "b" }, new Dictionary { ["Name"] = 1 }, + Array.Empty(), new List(), new Dictionary(), + new CyclicValue(), new ThrowingGetterValue(), typeof(string) + }; + + foreach (var value in values) + { + var node = JsonValue.Create(value)!; + Assert.Equal("|||||empty|T", nested(new JsonObject { ["items"] = node })); + Assert.Equal("|||||empty|T", root(node)); + } + } + + [Fact] + public void MembersDoNotResolveOnScalarValues() + { + var template = Handlebars.Create().Compile("{{items.Length}}|{{items.Year}}"); + + Assert.Equal("|", template(new JsonObject { ["items"] = "abc" })); + Assert.Equal("|", template(new JsonObject { ["items"] = new DateTime(2020, 1, 2) })); + Assert.Equal("|", template(Parse("{\"items\": \"abc\"}"))); + } + + [Fact] + public void ArrayMembersUseCustomAliases() + { + var handlebars = Handlebars.Create(); + handlebars.Configuration.AliasProviders.Add(new DelegatedMemberAliasProvider().AddAlias("itemCount", array => array.Count)); + var template = handlebars.Compile("{{arrayVal.itemCount}}"); + + Assert.Equal("2", template(Parse("{\"arrayVal\": [\"a\", \"b\"]}"))); + } + + [Theory] + [InlineData("Options", false)] + [InlineData("options", false)] + [InlineData("OPTIONS", false)] + [InlineData("Options", true)] + [InlineData("options", true)] + [InlineData("OPTIONS", true)] + public void ArrayMembersDoNotExposeNodeOptions(string member, bool useAliases) + { + var handlebars = Handlebars.Create(); + if (useAliases) + { + handlebars.Configuration.UseCollectionMemberAliasProvider(); + } + + var data = JsonNode.Parse("{\"items\": [1]}", new JsonNodeOptions { PropertyNameCaseInsensitive = true })!; + var template = handlebars.Compile("{{items." + member + "}}|{{items." + member + ".PropertyNameCaseInsensitive}}|{{lookup items '" + member + "'}}|{{#with (lookup items '" + member + "')}}{{PropertyNameCaseInsensitive}}{{/with}}"); + + Assert.Equal(true, data["items"]!.Options?.PropertyNameCaseInsensitive); + Assert.Equal("|||", template(data)); + } + + [Fact] + public void DescriptorSupportsConcreteNodeTypes() + { + var provider = new JsonNodeObjectDescriptorProvider(); + var nodes = new JsonNode[] { new JsonObject { ["stringVal"] = "a" }, new JsonArray(1), JsonValue.Create(false)!, JsonValue.Create(new CyclicValue())! }; + + foreach (var node in nodes) + { + Assert.True(provider.TryGetDescriptor(node.GetType(), out var descriptor)); + Assert.Equal(node.GetType(), descriptor!.DescribedType); + var properties = descriptor.GetProperties(descriptor, node).Cast().Select(property => property.ToString()); + Assert.Equal(node is JsonObject ? new[] { "stringVal" } : Array.Empty(), properties); + } + } + + private class CyclicValue + { + public CyclicValue Self => this; + } + + private class ThrowingGetterValue + { + public string Name => throw new InvalidOperationException("The wrapped value must not be inspected or serialized."); + } + } +} diff --git a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs index 89b25c20..b703461b 100644 --- a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs +++ b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs @@ -121,17 +121,19 @@ public HandlebarsConfigurationAdapter(HandlebarsConfiguration configuration) private ObservableList CreateObjectDescriptorProvider(ObservableList descriptorProviders) { var objectDescriptorProvider = new ObjectDescriptorProvider(AliasProviders); + var enumerableObjectDescriptor = new EnumerableObjectDescriptor(objectDescriptorProvider); var objectDescriptorProviders = new ObservableList { objectDescriptorProvider, new DynamicObjectDescriptor(objectDescriptorProvider), - new EnumerableObjectDescriptor(objectDescriptorProvider), + enumerableObjectDescriptor, new DictionaryObjectDescriptor(), new ReadOnlyGenericDictionaryObjectDescriptorProvider(), new GenericDictionaryObjectDescriptorProvider(), new ReadOnlyStringDictionaryObjectDescriptorProvider(), new StringDictionaryObjectDescriptorProvider(), - new JsonElementObjectDescriptorProvider(), + new JsonElementObjectDescriptorProvider(AliasProviders), + new JsonNodeObjectDescriptorProvider(AliasProviders), new LayoutViewModel.DescriptorProvider(), } .AddMany(descriptorProviders); diff --git a/source/Handlebars/HandlebarsUtils.cs b/source/Handlebars/HandlebarsUtils.cs index 455d5861..ca6102b2 100644 --- a/source/Handlebars/HandlebarsUtils.cs +++ b/source/Handlebars/HandlebarsUtils.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Nodes; namespace HandlebarsDotNet { @@ -38,6 +39,8 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero) return s == string.Empty; case JsonElement element: return IsFalsyJsonElement(element, includeZero); + case JsonNode node: + return IsFalsyJsonNode(node, includeZero); } if (includeZero) return false; @@ -80,6 +83,12 @@ private static bool IsFalsyJsonElement(JsonElement element, bool includeZero) } } + private static bool IsFalsyJsonNode(JsonNode node, bool includeZero) + { + // Parsed values wrap JsonElement; programmatically created values retain their CLR type. + return node is JsonValue scalar && IsFalsy(scalar.GetValue(), includeZero); + } + public static bool IsTruthyOrNonEmpty([NotNullWhen(true)] object? value, bool includeZero = false) { return !IsFalsyOrEmpty(value, includeZero); @@ -98,6 +107,16 @@ public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool inclu || (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 0); } + if (value is JsonNode node) + { + return node switch + { + JsonObject obj => obj.Count == 0, + JsonArray array => array.Count == 0, + _ => false + }; + } + // O(1) emptiness checks that avoid allocating an enumerator for the common // collection shapes; the IEnumerable fallback boxes List-style struct enumerators. if (value is ICollection collection) return collection.Count == 0; diff --git a/source/Handlebars/Iterators/JsonNodeIterator.cs b/source/Handlebars/Iterators/JsonNodeIterator.cs new file mode 100644 index 00000000..e02bc3c0 --- /dev/null +++ b/source/Handlebars/Iterators/JsonNodeIterator.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Nodes; +using HandlebarsDotNet.Compiler; +using HandlebarsDotNet.PathStructure; +using HandlebarsDotNet.Runtime; +using HandlebarsDotNet.ValueProviders; + +namespace HandlebarsDotNet.Iterators +{ + public sealed class JsonNodeIterator : IIterator + { + public void Iterate( + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + object input, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + if (input is JsonObject obj) + { + IterateObject(obj, writer, context, blockParamsVariables, template, ifEmpty); + return; + } + + using var innerContext = context.CreateFrame(); + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + + private static void IterateObject( + JsonObject target, + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + using var innerContext = context.CreateFrame(); + var iterator = new IteratorValues(innerContext); + var blockParamsValues = new BlockParamsValues(innerContext, blockParamsVariables); + + blockParamsValues.CreateProperty(0, out var _0); + blockParamsValues.CreateProperty(1, out var _1); + + iterator.First = BoxedValues.True; + iterator.Last = BoxedValues.False; + + var count = target.Count; + + var index = 0; + var lastIndex = count - 1; + foreach (var property in target) + { + if (index == 1) + { + iterator.First = BoxedValues.False; + } + if (index == lastIndex) + { + iterator.Last = BoxedValues.True; + } + + var value = property.Value; + var key = property.Key; + + iterator.Key = key; + iterator.Index = BoxedValues.Int(index); + + blockParamsValues[_0] = value; + blockParamsValues[_1] = key; + + iterator.Value = value; + innerContext.Value = value; + + template(writer, innerContext); + + ++index; + } + + if (index == 0) + { + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + } + } +} diff --git a/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs index 1c6307e0..3fa6d565 100644 --- a/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/JsonElementMemberAccessor.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text.Json; using HandlebarsDotNet.PathStructure; @@ -5,16 +7,71 @@ namespace HandlebarsDotNet.MemberAccessors { public sealed class JsonElementMemberAccessor : IMemberAccessor { - public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + private static readonly Type Type = typeof(JsonElement); + + private readonly IReadOnlyList _aliasProviders; + + public JsonElementMemberAccessor() : this(Array.Empty()) { - value = null; + } + public JsonElementMemberAccessor(IReadOnlyList aliasProviders) + { + _aliasProviders = aliasProviders; + } + + public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + { var element = (JsonElement) instance; - if (element.ValueKind != JsonValueKind.Object) + var found = element.ValueKind switch { - return false; + JsonValueKind.Object => TryGetPropertyValue(element, memberName, out value), + JsonValueKind.Array => TryGetArrayMember(element, memberName, out value), + _ => NotFound(out value) + }; + + return found || TryGetValueByAlias(instance, memberName, out value); + } + + private static bool TryGetArrayMember(JsonElement element, ChainSegment memberName, out object? value) + { + // Same index parsing as EnumerableMemberAccessor: non-negative integers only. + if (int.TryParse(memberName.LowerInvariant, out var index) && index >= 0 && index < element.GetArrayLength()) + { + value = element[index]; + return true; } + // Matches the Count property exposed by List and JsonArray; + // Length is added by the collection alias provider, as for other collections. + if (memberName.LowerInvariant == "count") + { + value = element.GetArrayLength(); + return true; + } + + return NotFound(out value); + } + + private bool TryGetValueByAlias(object instance, ChainSegment memberName, out object? value) + { + for (var index = 0; index < _aliasProviders.Count; index++) + { + if (_aliasProviders[index].TryGetMemberByAlias(instance, Type, memberName, out value)) + return true; + } + + return NotFound(out value); + } + + private static bool NotFound(out object? value) + { + value = null; + return false; + } + + private static bool TryGetPropertyValue(JsonElement element, ChainSegment memberName, out object? value) + { if (element.TryGetProperty(memberName.TrimmedValue, out var property)) { value = property; @@ -27,7 +84,7 @@ public bool TryGetValue(object instance, ChainSegment memberName, out object? va return true; } - return false; + return NotFound(out value); } } } diff --git a/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs new file mode 100644 index 00000000..3a301e47 --- /dev/null +++ b/source/Handlebars/MemberAccessors/JsonNodeMemberAccessor.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using HandlebarsDotNet.PathStructure; + +namespace HandlebarsDotNet.MemberAccessors +{ + public sealed class JsonNodeMemberAccessor : IMemberAccessor + { + private readonly IReadOnlyList _aliasProviders; + + public JsonNodeMemberAccessor() : this(Array.Empty()) + { + } + + public JsonNodeMemberAccessor(IReadOnlyList aliasProviders) + { + _aliasProviders = aliasProviders; + } + + public bool TryGetValue(object instance, ChainSegment memberName, out object? value) + { + if ((instance is JsonObject obj && TryGetPropertyValue(obj, memberName, out value)) || + (instance is JsonArray array && TryGetArrayMember(array, memberName, out value))) + { + return true; + } + + var type = instance.GetType(); + for (var index = 0; index < _aliasProviders.Count; index++) + { + if (_aliasProviders[index].TryGetMemberByAlias(instance, type, memberName, out value)) + { + return true; + } + } + + value = null; + return false; + } + + private static bool TryGetArrayMember(JsonArray array, ChainSegment memberName, out object? value) + { + if (int.TryParse(memberName.LowerInvariant, out var index) && index >= 0 && index < array.Count) + { + value = array[index]; + return true; + } + + if (memberName.LowerInvariant == "count") + { + value = array.Count; + return true; + } + + value = null; + return false; + } + + private static bool TryGetPropertyValue(JsonObject obj, ChainSegment memberName, out object? value) + { + if (obj.TryGetPropertyValue(memberName.TrimmedValue, out var property)) + { + value = property; + return true; + } + + if (memberName.LowerInvariant != memberName.TrimmedValue && obj.TryGetPropertyValue(memberName.LowerInvariant, out property)) + { + value = property; + return true; + } + + value = null; + return false; + } + } +} diff --git a/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs b/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs index 84336f1c..55a390d1 100644 --- a/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs +++ b/source/Handlebars/MemberAliasProvider/CollectionMemberAliasProvider.cs @@ -1,6 +1,8 @@ using System; using System.Collections; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using HandlebarsDotNet.ObjectDescriptors; using HandlebarsDotNet.PathStructure; @@ -23,6 +25,14 @@ public bool TryGetMemberByAlias(object instance, Type targetType, ChainSegment m value = array.Count; return true; + case JsonElement { ValueKind: JsonValueKind.Array } element when memberAlias.Equals(Length): + value = element.GetArrayLength(); + return true; + + case JsonArray array when memberAlias.Equals(Length): + value = array.Count; + return true; + case IEnumerable enumerable when ObjectDescriptorFactory.Current!.TryGetDescriptor(targetType, out var descriptor) && descriptor.GetProperties != null: var properties = descriptor.GetProperties(descriptor, enumerable); var property = properties.OfType().FirstOrDefault(o => @@ -42,4 +52,4 @@ public bool TryGetMemberByAlias(object instance, Type targetType, ChainSegment m } } } -} \ No newline at end of file +} diff --git a/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs index e8b6f73c..69b38749 100644 --- a/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs +++ b/source/Handlebars/ObjectDescriptors/JsonElementObjectDescriptorProvider.cs @@ -17,8 +17,6 @@ public sealed class JsonElementObjectDescriptorProvider : IObjectDescriptorProvi { private static readonly Type Type = typeof(JsonElement); - private static readonly JsonElementMemberAccessor MemberAccessor = new JsonElementMemberAccessor(); - private static readonly Func IteratorFactory = _ => new JsonElementIterator(); private static readonly Func GetProperties = (descriptor, arg) => @@ -29,7 +27,17 @@ public sealed class JsonElementObjectDescriptorProvider : IObjectDescriptorProvi : Enumerable.Empty(); }; - private static readonly ObjectDescriptor Descriptor = new ObjectDescriptor(Type, MemberAccessor, GetProperties, IteratorFactory); + private readonly ObjectDescriptor _descriptor; + + public JsonElementObjectDescriptorProvider() : this(Array.Empty()) + { + } + + /// Consulted for members a does not resolve itself, e.g. length on arrays. + public JsonElementObjectDescriptorProvider(IReadOnlyList aliasProviders) + { + _descriptor = new ObjectDescriptor(Type, new JsonElementMemberAccessor(aliasProviders), GetProperties, IteratorFactory); + } public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) { @@ -39,7 +47,7 @@ public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor return false; } - value = Descriptor; + value = _descriptor; return true; } } diff --git a/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs new file mode 100644 index 00000000..bf19e5d4 --- /dev/null +++ b/source/Handlebars/ObjectDescriptors/JsonNodeObjectDescriptorProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Nodes; +using HandlebarsDotNet.Iterators; +using HandlebarsDotNet.MemberAccessors; + +namespace HandlebarsDotNet.ObjectDescriptors +{ + /// + /// Provides support for in templates. + /// + public sealed class JsonNodeObjectDescriptorProvider : IObjectDescriptorProvider + { + private static readonly Type Type = typeof(JsonNode); + + private readonly JsonNodeMemberAccessor _memberAccessor; + + private static readonly Func IteratorFactory = descriptor => + descriptor.DescribedType == typeof(JsonArray) + ? new ListIterator() + : new JsonNodeIterator(); + + private static readonly Func GetProperties = (descriptor, arg) => + arg is JsonObject obj + ? obj.Select(property => property.Key) + : Enumerable.Empty(); + + public JsonNodeObjectDescriptorProvider() : this(Array.Empty()) + { + } + + /// Consulted for members a does not resolve itself. + public JsonNodeObjectDescriptorProvider(IReadOnlyList aliasProviders) + { + _memberAccessor = new JsonNodeMemberAccessor(aliasProviders); + } + + public bool TryGetDescriptor(Type type, [NotNullWhen(true)] out ObjectDescriptor? value) + { + if (!Type.IsAssignableFrom(type)) + { + value = ObjectDescriptor.Empty; + return false; + } + + value = new ObjectDescriptor(type, _memberAccessor, GetProperties, IteratorFactory); + return true; + } + } +}