From bef468ff0684b94be09466adb8ef216b10f58a7a Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Fri, 6 Mar 2026 22:46:04 +0300 Subject: [PATCH 1/7] chore: deeply nested property updates --- .../CognitiveMapTests.cs | 77 ++++++++++++++++++ .../MoqModels/CustomerPayment.cs | 7 ++ .../Engine/EngineAlgorithim.cs | 81 ++++++++++++++++++- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/ObjectSemantics.NET.Tests/CognitiveMapTests.cs b/ObjectSemantics.NET.Tests/CognitiveMapTests.cs index 4fd05a2..8d6f6eb 100644 --- a/ObjectSemantics.NET.Tests/CognitiveMapTests.cs +++ b/ObjectSemantics.NET.Tests/CognitiveMapTests.cs @@ -33,6 +33,83 @@ public void Library_Entry_Point_String_Extension_Should_Work(string personName) Assert.Equal($"I am {personName}!", generatedTemplate); } + [Fact] + public void Property_Of_Object_Should_Be_Mapped() + { + CustomerPayment customerPayment = new CustomerPayment + { + Amount = 100_000_000, + Customer = new Customer + { + CompanyName = "CRUDSOFT TECHNOLOGIES" + } + }; + string generatedTemplate = "Paid Amount: {{ Amount:N2 }} By {{ Customer.CompanyName }}".Map(customerPayment); + Assert.Equal("Paid Amount: 100,000,000.00 By CRUDSOFT TECHNOLOGIES", generatedTemplate); + } + + [Fact] + public void Nested_Property_Should_Be_Empty_When_Parent_Object_Is_Null() + { + CustomerPayment customerPayment = new CustomerPayment + { + Amount = 100_000_000, + Customer = null + }; + + string generatedTemplate = "Paid Amount: {{ Amount:N2 }} By {{ Customer.CompanyName }}".Map(customerPayment); + Assert.Equal("Paid Amount: 100,000,000.00 By ", generatedTemplate); + } + + [Fact] + public void Nested_Property_Should_Be_Empty_When_Target_Property_Is_Null() + { + CustomerPayment customerPayment = new CustomerPayment + { + Amount = 100_000_000, + Customer = new Customer + { + CompanyName = null + } + }; + + string generatedTemplate = "Paid Amount: {{ Amount:N2 }} By {{ Customer.CompanyName }}".Map(customerPayment); + Assert.Equal("Paid Amount: 100,000,000.00 By ", generatedTemplate); + } + + [Fact] + public void Deeply_Nested_Property_Should_Be_Mapped() + { + CustomerPayment customerPayment = new CustomerPayment + { + Amount = 100_000_000, + Customer = new Customer + { + BankingDetail = new CustomerBankingDetail + { + BankName = "KCB BANK" + } + } + }; + + string generatedTemplate = "Paid Amount: {{ Amount:N2 }} Via {{ Customer.BankingDetail.BankName }}".Map(customerPayment); + Assert.Equal("Paid Amount: 100,000,000.00 Via KCB BANK", generatedTemplate); + } + + [Fact] + public void Deeply_Nested_Property_Should_Be_Empty_When_Object_Is_Null() + { + CustomerPayment customerPayment = new CustomerPayment + { + Amount = 100_000_000, + Customer = null + }; + + string generatedTemplate = "Paid Amount: {{ Amount:N2 }} Via {{ Customer.BankingDetail.BankName }}".Map(customerPayment); + Assert.Equal("Paid Amount: 100,000,000.00 Via ", generatedTemplate); + } + + [Fact] public void Additional_Headers_Should_Also_Be_Mapped() { diff --git a/ObjectSemantics.NET.Tests/MoqModels/CustomerPayment.cs b/ObjectSemantics.NET.Tests/MoqModels/CustomerPayment.cs index 1fbf860..4e33577 100644 --- a/ObjectSemantics.NET.Tests/MoqModels/CustomerPayment.cs +++ b/ObjectSemantics.NET.Tests/MoqModels/CustomerPayment.cs @@ -24,5 +24,12 @@ public class Customer public string FirstName { get; set; } public string LastName { get; set; } public string CompanyName { get; set; } + public CustomerBankingDetail BankingDetail { get; set; } + } + + public class CustomerBankingDetail + { + public string BankName { get; set; } + public string AccountNumber { get; set; } } } diff --git a/ObjectSemantics.NET/Engine/EngineAlgorithim.cs b/ObjectSemantics.NET/Engine/EngineAlgorithim.cs index c3bce8a..c2ecf0b 100644 --- a/ObjectSemantics.NET/Engine/EngineAlgorithim.cs +++ b/ObjectSemantics.NET/Engine/EngineAlgorithim.cs @@ -29,7 +29,7 @@ internal static class EngineAlgorithim // ---- IF Conditions ---- foreach (ReplaceIfOperationCode ifCondition in template.ReplaceIfConditionCodes) { - if (!propMap.TryGetValue(ifCondition.IfPropertyName, out ExtractedObjProperty property)) + if (!TryResolveProperty(propMap, ifCondition.IfPropertyName, out ExtractedObjProperty property)) { result.ReplaceFirstOccurrence(ifCondition.ReplaceRef, "[IF-CONDITION EXCEPTION]: unrecognized property: [" + ifCondition.IfPropertyName + "]"); continue; @@ -89,7 +89,7 @@ internal static class EngineAlgorithim } else { - if (rowMap.TryGetValue(propName, out ExtractedObjProperty p)) + if (TryResolveProperty(rowMap, propName, out ExtractedObjProperty p)) activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, p.GetPropertyDisplayString(objLoopCode.GetFormattingCommand(), options)); else activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, objLoopCode.ReplaceCommand); @@ -105,7 +105,7 @@ internal static class EngineAlgorithim // ---- Direct Replacements ---- foreach (ReplaceCode replaceCode in template.ReplaceCodes) { - if (propMap.TryGetValue(replaceCode.GetTargetPropertyName(), out ExtractedObjProperty property)) + if (TryResolveProperty(propMap, replaceCode.GetTargetPropertyName(), out ExtractedObjProperty property)) result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, property.GetPropertyDisplayString(replaceCode.GetFormattingCommand(), options)); else result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, "{{ " + replaceCode.ReplaceCommand + " }}"); @@ -236,5 +236,78 @@ private static List GetObjPropertiesFromUnknown(object val return result; } + + private static bool TryResolveProperty(Dictionary propMap, string propertyPath, out ExtractedObjProperty result) + { + result = null; + if (propMap == null || string.IsNullOrWhiteSpace(propertyPath)) + return false; + + string path = propertyPath.Trim(); + if (propMap.TryGetValue(path, out result)) + return true; + + int dotIndex = path.IndexOf('.'); + if (dotIndex < 0) + return false; + + string rootName = path.Substring(0, dotIndex).Trim(); + string nestedPath = path.Substring(dotIndex + 1).Trim(); + if (string.IsNullOrEmpty(rootName) || string.IsNullOrEmpty(nestedPath)) + return false; + + if (!propMap.TryGetValue(rootName, out ExtractedObjProperty rootProperty)) + return false; + + return TryResolveNestedProperty(rootProperty, nestedPath, path, out result); + } + + private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string nestedPath, string fullPath, out ExtractedObjProperty result) + { + result = null; + if (rootProperty == null || string.IsNullOrWhiteSpace(nestedPath)) + return false; + + string[] segments = nestedPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + return false; + + object currentValue = rootProperty.OriginalValue; + Type currentType = rootProperty.Type; + + for (int i = 0; i < segments.Length; i++) + { + if (currentType == null) + return false; + + string segment = segments[i].Trim(); + if (string.IsNullOrEmpty(segment)) + return false; + + PropertyInfo nextProperty = currentType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(p => p.GetIndexParameters().Length == 0 && string.Equals(p.Name, segment, StringComparison.OrdinalIgnoreCase)); + + if (nextProperty == null) + return false; + + object nextValue = currentValue == null ? null : nextProperty.GetValue(currentValue, null); + if (i == segments.Length - 1) + { + result = new ExtractedObjProperty + { + Name = fullPath, + Type = nextProperty.PropertyType, + OriginalValue = nextValue + }; + return true; + } + + currentType = nextProperty.PropertyType; + currentValue = nextValue; + } + + return false; + } } -} \ No newline at end of file +} From f80ad9ad9b9284780f08c3d784e912b9e82c1364 Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Fri, 6 Mar 2026 23:20:23 +0300 Subject: [PATCH 2/7] chore: performance grade optimized --- .../Engine/EngineAlgorithim.cs | 299 +----------------- .../Engine/EnginePropertyResolver.cs | 115 +++++++ .../Engine/EngineTemplateCache.cs | 28 ++ .../Engine/EngineTemplateParser.cs | 103 ++++++ .../Engine/EngineTemplateRenderer.cs | 125 ++++++++ .../Engine/EngineTypeMetadataCache.cs | 133 ++++++++ .../Engine/Models/ReplaceCode.cs | 4 +- 7 files changed, 509 insertions(+), 298 deletions(-) create mode 100644 ObjectSemantics.NET/Engine/EnginePropertyResolver.cs create mode 100644 ObjectSemantics.NET/Engine/EngineTemplateCache.cs create mode 100644 ObjectSemantics.NET/Engine/EngineTemplateParser.cs create mode 100644 ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs create mode 100644 ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs diff --git a/ObjectSemantics.NET/Engine/EngineAlgorithim.cs b/ObjectSemantics.NET/Engine/EngineAlgorithim.cs index c2ecf0b..694e938 100644 --- a/ObjectSemantics.NET/Engine/EngineAlgorithim.cs +++ b/ObjectSemantics.NET/Engine/EngineAlgorithim.cs @@ -1,313 +1,18 @@ -using ObjectSemantics.NET.Engine.Extensions; using ObjectSemantics.NET.Engine.Models; -using System; -using System.Collections; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; namespace ObjectSemantics.NET.Engine { internal static class EngineAlgorithim { - private static readonly ConcurrentDictionary PropertyCache = new ConcurrentDictionary(); - - private static readonly Regex IfConditionRegex = new Regex(@"{{\s*#\s*if\s*\(\s*(?\w+)\s*(?==|!=|>=|<=|>|<)\s*(?[^)]+?)\s*\)\s*}}(?[\s\S]*?)(?:{{\s*#\s*else\s*}}(?[\s\S]*?))?{{\s*#\s*endif\s*}}", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex LoopBlockRegex = new Regex(@"{{\s*#\s*foreach\s*\(\s*(\w+)\s*\)\s*\}\}([\s\S]*?)\{\{\s*#\s*endforeach\s*}}", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex DirectParamRegex = new Regex(@"{{(.+?)}}", RegexOptions.IgnoreCase | RegexOptions.Compiled); - public static string GenerateFromTemplate(T record, EngineRunnerTemplate template, Dictionary parameterKeyValues = null, TemplateMapperOptions options = null) where T : new() { - List objProperties = GetObjectProperties(record, parameterKeyValues); - Dictionary propMap = objProperties.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - - StringBuilder result = new StringBuilder(template.Template ?? string.Empty, (template.Template?.Length ?? 0) * 2); - - // ---- IF Conditions ---- - foreach (ReplaceIfOperationCode ifCondition in template.ReplaceIfConditionCodes) - { - if (!TryResolveProperty(propMap, ifCondition.IfPropertyName, out ExtractedObjProperty property)) - { - result.ReplaceFirstOccurrence(ifCondition.ReplaceRef, "[IF-CONDITION EXCEPTION]: unrecognized property: [" + ifCondition.IfPropertyName + "]"); - continue; - } - - bool conditionPassed = property.IsPropertyValueConditionPassed(ifCondition.IfOperationValue, ifCondition.IfOperationType); - string replacement; - - if (conditionPassed) - { - EngineRunnerTemplate trueContent = GenerateRunnerTemplate(ifCondition.IfOperationTrueTemplate); - replacement = GenerateFromTemplate(record, trueContent, parameterKeyValues, options); - } - else if (!string.IsNullOrEmpty(ifCondition.IfOperationFalseTemplate)) - { - EngineRunnerTemplate falseContent = GenerateRunnerTemplate(ifCondition.IfOperationFalseTemplate); - replacement = GenerateFromTemplate(record, falseContent, parameterKeyValues, options); - } - else - { - replacement = string.Empty; - } - - result.ReplaceFirstOccurrence(ifCondition.ReplaceRef, replacement); - } - - // ---- Object Loops ---- - foreach (ReplaceObjLoopCode objLoop in template.ReplaceObjLoopCodes) - { - if (!propMap.TryGetValue(objLoop.TargetObjectName, out ExtractedObjProperty targetObj) || !(targetObj.OriginalValue is IEnumerable enumerable)) - { - result.ReplaceFirstOccurrence(objLoop.ReplaceRef, string.Empty); - continue; - } - - StringBuilder loopResult = new StringBuilder(); - - foreach (object row in enumerable) - { - List rowProps = GetObjPropertiesFromUnknown(row); - Dictionary rowMap = rowProps.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - - StringBuilder activeRow = new StringBuilder(objLoop.ObjLoopTemplate); - - foreach (ReplaceCode objLoopCode in objLoop.ReplaceObjCodes) - { - string propName = objLoopCode.GetTargetPropertyName(); - if (propName == ".") - { - ExtractedObjProperty tempProp = new ExtractedObjProperty - { - Name = ".", - Type = row.GetType(), - OriginalValue = row - }; - activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, tempProp.GetPropertyDisplayString(objLoopCode.GetFormattingCommand(), options)); - } - else - { - if (TryResolveProperty(rowMap, propName, out ExtractedObjProperty p)) - activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, p.GetPropertyDisplayString(objLoopCode.GetFormattingCommand(), options)); - else - activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, objLoopCode.ReplaceCommand); - } - } - - loopResult.Append(activeRow); - } - - result.ReplaceFirstOccurrence(objLoop.ReplaceRef, loopResult.ToString()); - } - - // ---- Direct Replacements ---- - foreach (ReplaceCode replaceCode in template.ReplaceCodes) - { - if (TryResolveProperty(propMap, replaceCode.GetTargetPropertyName(), out ExtractedObjProperty property)) - result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, property.GetPropertyDisplayString(replaceCode.GetFormattingCommand(), options)); - else - result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, "{{ " + replaceCode.ReplaceCommand + " }}"); - } - - return result.ToString(); + return EngineTemplateRenderer.Render(record, template, parameterKeyValues, options); } internal static EngineRunnerTemplate GenerateRunnerTemplate(string fileContent) { - EngineRunnerTemplate templatedContent = new EngineRunnerTemplate { Template = fileContent }; - long key = 0; - - // ---- IF Conditions ---- - templatedContent.Template = IfConditionRegex.Replace(templatedContent.Template, m => - { - key++; - string refKey = "RIB_" + key; - templatedContent.ReplaceIfConditionCodes.Add(new ReplaceIfOperationCode - { - ReplaceRef = refKey, - IfPropertyName = m.Groups["param"].Value, - IfOperationType = m.Groups["operator"].Value, - IfOperationValue = m.Groups["value"].Value, - IfOperationTrueTemplate = m.Groups["code"].Value, - IfOperationFalseTemplate = m.Groups["else"].Success ? m.Groups["else"].Value : string.Empty - }); - return refKey; - }); - - // ---- FOREACH Loops ---- - templatedContent.Template = LoopBlockRegex.Replace(templatedContent.Template, m => - { - key++; - string refKey = "RLB_" + key; - ReplaceObjLoopCode objLoop = new ReplaceObjLoopCode - { - ReplaceRef = refKey, - TargetObjectName = m.Groups[1].Value?.Trim() ?? string.Empty - }; - - string loopBlock = m.Groups[2].Value; - loopBlock = DirectParamRegex.Replace(loopBlock, pm => - { - key++; - string loopRef = "RLBR_" + key; - objLoop.ReplaceObjCodes.Add(new ReplaceCode - { - ReplaceCommand = pm.Groups[1].Value.Trim(), - ReplaceRef = loopRef - }); - return loopRef; - }); - - objLoop.ObjLoopTemplate = loopBlock; - templatedContent.ReplaceObjLoopCodes.Add(objLoop); - return refKey; - }); - - // ---- Direct Parameters ---- - templatedContent.Template = DirectParamRegex.Replace(templatedContent.Template, m => - { - key++; - string refKey = "RP_" + key; - templatedContent.ReplaceCodes.Add(new ReplaceCode - { - ReplaceCommand = m.Groups[1].Value.Trim(), - ReplaceRef = refKey - }); - return refKey; - }); - - return templatedContent; - } - - private static List GetObjectProperties(T value, Dictionary parameters) where T : new() - { - Type type = typeof(T); - if (!PropertyCache.TryGetValue(type, out PropertyInfo[] props)) - { - props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - PropertyCache[type] = props; - } - - List result = new List(props.Length + (parameters != null ? parameters.Count : 0)); - - foreach (PropertyInfo prop in props) - { - result.Add(new ExtractedObjProperty - { - Type = prop.PropertyType, - Name = prop.Name, - OriginalValue = value == null ? null : prop.GetValue(value, null) - }); - } - - if (parameters != null) - { - foreach (KeyValuePair p in parameters) - result.Add(new ExtractedObjProperty { Type = p.Value.GetType(), Name = p.Key, OriginalValue = p.Value }); - } - - return result; - } - - private static List GetObjPropertiesFromUnknown(object value) - { - if (value == null) return new List(); - - Type type = value.GetType(); - if (!PropertyCache.TryGetValue(type, out PropertyInfo[] props)) - { - props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.GetIndexParameters().Length == 0).ToArray(); - PropertyCache[type] = props; - } - - List result = new List(props.Length); - foreach (PropertyInfo prop in props) - { - result.Add(new ExtractedObjProperty - { - Type = prop.PropertyType, - Name = prop.Name, - OriginalValue = prop.GetValue(value, null) - }); - } - - return result; - } - - private static bool TryResolveProperty(Dictionary propMap, string propertyPath, out ExtractedObjProperty result) - { - result = null; - if (propMap == null || string.IsNullOrWhiteSpace(propertyPath)) - return false; - - string path = propertyPath.Trim(); - if (propMap.TryGetValue(path, out result)) - return true; - - int dotIndex = path.IndexOf('.'); - if (dotIndex < 0) - return false; - - string rootName = path.Substring(0, dotIndex).Trim(); - string nestedPath = path.Substring(dotIndex + 1).Trim(); - if (string.IsNullOrEmpty(rootName) || string.IsNullOrEmpty(nestedPath)) - return false; - - if (!propMap.TryGetValue(rootName, out ExtractedObjProperty rootProperty)) - return false; - - return TryResolveNestedProperty(rootProperty, nestedPath, path, out result); - } - - private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string nestedPath, string fullPath, out ExtractedObjProperty result) - { - result = null; - if (rootProperty == null || string.IsNullOrWhiteSpace(nestedPath)) - return false; - - string[] segments = nestedPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - return false; - - object currentValue = rootProperty.OriginalValue; - Type currentType = rootProperty.Type; - - for (int i = 0; i < segments.Length; i++) - { - if (currentType == null) - return false; - - string segment = segments[i].Trim(); - if (string.IsNullOrEmpty(segment)) - return false; - - PropertyInfo nextProperty = currentType - .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .FirstOrDefault(p => p.GetIndexParameters().Length == 0 && string.Equals(p.Name, segment, StringComparison.OrdinalIgnoreCase)); - - if (nextProperty == null) - return false; - - object nextValue = currentValue == null ? null : nextProperty.GetValue(currentValue, null); - if (i == segments.Length - 1) - { - result = new ExtractedObjProperty - { - Name = fullPath, - Type = nextProperty.PropertyType, - OriginalValue = nextValue - }; - return true; - } - - currentType = nextProperty.PropertyType; - currentValue = nextValue; - } - - return false; + return EngineTemplateCache.GetOrAdd(fileContent, EngineTemplateParser.Parse); } } } diff --git a/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs b/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs new file mode 100644 index 0000000..729b97c --- /dev/null +++ b/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs @@ -0,0 +1,115 @@ +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace ObjectSemantics.NET.Engine +{ + internal static class EnginePropertyResolver + { + private static readonly ConcurrentDictionary PropertyPathSegmentsCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + public static bool TryResolveProperty(Dictionary propMap, string propertyPath, out ExtractedObjProperty result) + { + result = null; + if (propMap == null || string.IsNullOrWhiteSpace(propertyPath)) + return false; + + string path = propertyPath.Trim(); + if (propMap.TryGetValue(path, out result)) + return true; + + int dotIndex = path.IndexOf('.'); + if (dotIndex < 0) + return false; + + string rootName = path.Substring(0, dotIndex).Trim(); + string nestedPath = path.Substring(dotIndex + 1).Trim(); + if (string.IsNullOrEmpty(rootName) || string.IsNullOrEmpty(nestedPath)) + return false; + + if (!propMap.TryGetValue(rootName, out ExtractedObjProperty rootProperty)) + return false; + + return TryResolveNestedProperty(rootProperty, nestedPath, path, out result); + } + + private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string nestedPath, string fullPath, out ExtractedObjProperty result) + { + result = null; + if (rootProperty == null || string.IsNullOrWhiteSpace(nestedPath)) + return false; + + string[] segments = GetPathSegments(nestedPath); + if (segments.Length == 0) + return false; + + object currentValue = rootProperty.OriginalValue; + Type currentType = rootProperty.Type; + + for (int i = 0; i < segments.Length; i++) + { + if (currentType == null) + return false; + + string segment = segments[i].Trim(); + if (string.IsNullOrEmpty(segment)) + return false; + + if (!EngineTypeMetadataCache.TryGetPropertyAccessor(currentType, segment, out PropertyAccessor nextAccessor)) + return false; + + object nextValue = currentValue == null ? null : nextAccessor.Getter(currentValue); + currentType = nextAccessor.PropertyType; + + if (i == segments.Length - 1) + { + result = new ExtractedObjProperty + { + Name = fullPath, + Type = currentType, + OriginalValue = nextValue + }; + return true; + } + + currentValue = nextValue; + } + + return false; + } + + private static string[] GetPathSegments(string nestedPath) + { + return PropertyPathSegmentsCache.GetOrAdd(nestedPath, SplitPathSegments); + } + + private static string[] SplitPathSegments(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return Array.Empty(); + + string[] rawSegments = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + if (rawSegments.Length == 0) + return rawSegments; + + int validCount = 0; + for (int i = 0; i < rawSegments.Length; i++) + { + string trimmed = rawSegments[i].Trim(); + if (trimmed.Length == 0) + continue; + + rawSegments[validCount] = trimmed; + validCount++; + } + + if (validCount == rawSegments.Length) + return rawSegments; + + string[] segments = new string[validCount]; + Array.Copy(rawSegments, segments, validCount); + return segments; + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineTemplateCache.cs b/ObjectSemantics.NET/Engine/EngineTemplateCache.cs new file mode 100644 index 0000000..ffee25b --- /dev/null +++ b/ObjectSemantics.NET/Engine/EngineTemplateCache.cs @@ -0,0 +1,28 @@ +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections.Concurrent; + +namespace ObjectSemantics.NET.Engine +{ + internal static class EngineTemplateCache + { + private const int MaxEntries = 2048; + + private static readonly ConcurrentDictionary Cache = new ConcurrentDictionary(StringComparer.Ordinal); + + public static EngineRunnerTemplate GetOrAdd(string templateContent, Func factory) + { + string key = templateContent ?? string.Empty; + if (Cache.TryGetValue(key, out EngineRunnerTemplate cachedTemplate)) + return cachedTemplate; + + EngineRunnerTemplate createdTemplate = factory == null ? new EngineRunnerTemplate { Template = key } : factory(key); + + if (Cache.Count >= MaxEntries) + Cache.Clear(); + + Cache.TryAdd(key, createdTemplate); + return createdTemplate; + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineTemplateParser.cs b/ObjectSemantics.NET/Engine/EngineTemplateParser.cs new file mode 100644 index 0000000..3a129c0 --- /dev/null +++ b/ObjectSemantics.NET/Engine/EngineTemplateParser.cs @@ -0,0 +1,103 @@ +using ObjectSemantics.NET.Engine.Models; +using System.Text.RegularExpressions; + +namespace ObjectSemantics.NET.Engine +{ + internal static class EngineTemplateParser + { + private static readonly Regex IfConditionRegex = new Regex(@"{{\s*#\s*if\s*\(\s*(?[\w\.]+)\s*(?==|!=|>=|<=|>|<)\s*(?[^)]+?)\s*\)\s*}}(?[\s\S]*?)(?:{{\s*#\s*else\s*}}(?[\s\S]*?))?{{\s*#\s*endif\s*}}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex LoopBlockRegex = new Regex(@"{{\s*#\s*foreach\s*\(\s*(?[\w\.]+)\s*\)\s*}}(?[\s\S]*?){{\s*#\s*endforeach\s*}}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex DirectParamRegex = new Regex(@"{{(.+?)}}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static EngineRunnerTemplate Parse(string templateContent) + { + EngineRunnerTemplate templatedContent = new EngineRunnerTemplate { Template = templateContent ?? string.Empty }; + long key = 0; + + templatedContent.Template = IfConditionRegex.Replace(templatedContent.Template, m => + { + key++; + string refKey = "RIB_" + key; + templatedContent.ReplaceIfConditionCodes.Add(new ReplaceIfOperationCode + { + ReplaceRef = refKey, + IfPropertyName = m.Groups["param"].Value, + IfOperationType = m.Groups["operator"].Value, + IfOperationValue = m.Groups["value"].Value, + IfOperationTrueTemplate = m.Groups["code"].Value, + IfOperationFalseTemplate = m.Groups["else"].Success ? m.Groups["else"].Value : string.Empty + }); + return refKey; + }); + + templatedContent.Template = LoopBlockRegex.Replace(templatedContent.Template, m => + { + key++; + string refKey = "RLB_" + key; + ReplaceObjLoopCode objLoop = new ReplaceObjLoopCode + { + ReplaceRef = refKey, + TargetObjectName = m.Groups["target"].Value?.Trim() ?? string.Empty + }; + + string loopBlock = m.Groups["body"].Value; + loopBlock = DirectParamRegex.Replace(loopBlock, pm => + { + key++; + string loopRef = "RLBR_" + key; + objLoop.ReplaceObjCodes.Add(CreateReplaceCode(loopRef, pm.Groups[1].Value)); + return loopRef; + }); + + objLoop.ObjLoopTemplate = loopBlock; + templatedContent.ReplaceObjLoopCodes.Add(objLoop); + return refKey; + }); + + templatedContent.Template = DirectParamRegex.Replace(templatedContent.Template, m => + { + key++; + string refKey = "RP_" + key; + templatedContent.ReplaceCodes.Add(CreateReplaceCode(refKey, m.Groups[1].Value)); + return refKey; + }); + + return templatedContent; + } + + private static ReplaceCode CreateReplaceCode(string replaceRef, string replaceCommand) + { + string command = replaceCommand?.Trim() ?? string.Empty; + ParseReplaceCommand(command, out string targetPropertyName, out string formattingCommand); + + return new ReplaceCode + { + ReplaceRef = replaceRef, + ReplaceCommand = command, + TargetPropertyName = targetPropertyName, + FormattingCommand = formattingCommand + }; + } + + private static void ParseReplaceCommand(string replaceCommand, out string targetPropertyName, out string formattingCommand) + { + if (string.IsNullOrEmpty(replaceCommand)) + { + targetPropertyName = string.Empty; + formattingCommand = string.Empty; + return; + } + + int colonIndex = replaceCommand.IndexOf(':'); + if (colonIndex > 0) + { + targetPropertyName = replaceCommand.Substring(0, colonIndex).Trim(); + formattingCommand = colonIndex < replaceCommand.Length - 1 ? replaceCommand.Substring(colonIndex + 1).Trim() : string.Empty; + return; + } + + targetPropertyName = replaceCommand.Trim(); + formattingCommand = string.Empty; + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs new file mode 100644 index 0000000..9ec2975 --- /dev/null +++ b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs @@ -0,0 +1,125 @@ +using ObjectSemantics.NET.Engine.Extensions; +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; + +namespace ObjectSemantics.NET.Engine +{ + internal static class EngineTemplateRenderer + { + public static string Render(T record, EngineRunnerTemplate template, Dictionary parameterKeyValues = null, TemplateMapperOptions options = null) where T : new() + { + EngineRunnerTemplate activeTemplate = template ?? new EngineRunnerTemplate(); + Dictionary propMap = EngineTypeMetadataCache.BuildPropertyMap(record, typeof(T), parameterKeyValues); + + string templateText = activeTemplate.Template ?? string.Empty; + StringBuilder result = new StringBuilder(templateText, templateText.Length * 2); + + List ifConditions = activeTemplate.ReplaceIfConditionCodes; + for (int i = 0; i < ifConditions.Count; i++) + { + ReplaceIfOperationCode ifCondition = ifConditions[i]; + if (!EnginePropertyResolver.TryResolveProperty(propMap, ifCondition.IfPropertyName, out ExtractedObjProperty property)) + { + result.ReplaceFirstOccurrence(ifCondition.ReplaceRef, "[IF-CONDITION EXCEPTION]: unrecognized property: [" + ifCondition.IfPropertyName + "]"); + continue; + } + + bool conditionPassed = property.IsPropertyValueConditionPassed(ifCondition.IfOperationValue, ifCondition.IfOperationType); + string replacement; + + if (conditionPassed) + { + EngineRunnerTemplate trueContent = EngineAlgorithim.GenerateRunnerTemplate(ifCondition.IfOperationTrueTemplate); + replacement = Render(record, trueContent, parameterKeyValues, options); + } + else if (!string.IsNullOrEmpty(ifCondition.IfOperationFalseTemplate)) + { + EngineRunnerTemplate falseContent = EngineAlgorithim.GenerateRunnerTemplate(ifCondition.IfOperationFalseTemplate); + replacement = Render(record, falseContent, parameterKeyValues, options); + } + else + { + replacement = string.Empty; + } + + result.ReplaceFirstOccurrence(ifCondition.ReplaceRef, replacement); + } + + List loopCodes = activeTemplate.ReplaceObjLoopCodes; + for (int loopIndex = 0; loopIndex < loopCodes.Count; loopIndex++) + { + ReplaceObjLoopCode objLoop = loopCodes[loopIndex]; + if (!EnginePropertyResolver.TryResolveProperty(propMap, objLoop.TargetObjectName, out ExtractedObjProperty targetObj) || !(targetObj.OriginalValue is IEnumerable enumerable)) + { + result.ReplaceFirstOccurrence(objLoop.ReplaceRef, string.Empty); + continue; + } + + StringBuilder loopResult = new StringBuilder(); + List objLoopReplaceCodes = objLoop.ReplaceObjCodes; + + foreach (object row in enumerable) + { + Dictionary rowMap = null; + Type rowType = row != null ? row.GetType() : typeof(object); + ExtractedObjProperty currentRowProperty = null; + StringBuilder activeRow = new StringBuilder(objLoop.ObjLoopTemplate ?? string.Empty); + + for (int codeIndex = 0; codeIndex < objLoopReplaceCodes.Count; codeIndex++) + { + ReplaceCode objLoopCode = objLoopReplaceCodes[codeIndex]; + string propName = objLoopCode.TargetPropertyName ?? objLoopCode.GetTargetPropertyName(); + string formattingCommand = objLoopCode.FormattingCommand ?? objLoopCode.GetFormattingCommand(); + + if (propName == ".") + { + if (currentRowProperty == null) + { + currentRowProperty = new ExtractedObjProperty + { + Name = ".", + Type = rowType, + OriginalValue = row + }; + } + + activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, currentRowProperty.GetPropertyDisplayString(formattingCommand, options)); + } + else + { + if (rowMap == null) + rowMap = EngineTypeMetadataCache.BuildPropertyMap(row, rowType, null); + + if (EnginePropertyResolver.TryResolveProperty(rowMap, propName, out ExtractedObjProperty rowProperty)) + activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, rowProperty.GetPropertyDisplayString(formattingCommand, options)); + else + activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, objLoopCode.ReplaceCommand); + } + } + + loopResult.Append(activeRow); + } + + result.ReplaceFirstOccurrence(objLoop.ReplaceRef, loopResult.ToString()); + } + + List replaceCodes = activeTemplate.ReplaceCodes; + for (int i = 0; i < replaceCodes.Count; i++) + { + ReplaceCode replaceCode = replaceCodes[i]; + string targetPropertyName = replaceCode.TargetPropertyName ?? replaceCode.GetTargetPropertyName(); + string formattingCommand = replaceCode.FormattingCommand ?? replaceCode.GetFormattingCommand(); + + if (EnginePropertyResolver.TryResolveProperty(propMap, targetPropertyName, out ExtractedObjProperty property)) + result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, property.GetPropertyDisplayString(formattingCommand, options)); + else + result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, "{{ " + replaceCode.ReplaceCommand + " }}"); + } + + return result.ToString(); + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs b/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs new file mode 100644 index 0000000..33917ef --- /dev/null +++ b/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs @@ -0,0 +1,133 @@ +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace ObjectSemantics.NET.Engine +{ + internal static class EngineTypeMetadataCache + { + private static readonly ConcurrentDictionary TypeCacheMap = new ConcurrentDictionary(); + + private static readonly TypePropertyCache EmptyTypePropertyCache = new TypePropertyCache(Array.Empty(), new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public static Dictionary BuildPropertyMap(object value, Type declaredType, Dictionary parameters) + { + TypePropertyCache typeCache = GetTypePropertyCache(declaredType); + int capacity = typeCache.Accessors.Length + (parameters != null ? parameters.Count : 0); + Dictionary propertyMap = new Dictionary(capacity, StringComparer.OrdinalIgnoreCase); + + PropertyAccessor[] accessors = typeCache.Accessors; + for (int i = 0; i < accessors.Length; i++) + { + PropertyAccessor accessor = accessors[i]; + propertyMap.Add(accessor.Name, new ExtractedObjProperty + { + Type = accessor.PropertyType, + Name = accessor.Name, + OriginalValue = value == null ? null : accessor.Getter(value) + }); + } + + if (parameters != null) + { + foreach (KeyValuePair p in parameters) + { + propertyMap.Add(p.Key, new ExtractedObjProperty + { + Type = p.Value != null ? p.Value.GetType() : typeof(object), + Name = p.Key, + OriginalValue = p.Value + }); + } + } + + return propertyMap; + } + + public static bool TryGetPropertyAccessor(Type type, string propertyName, out PropertyAccessor accessor) + { + accessor = null; + if (type == null || string.IsNullOrWhiteSpace(propertyName)) + return false; + + TypePropertyCache typeCache = GetTypePropertyCache(type); + return typeCache.PropertyMap.TryGetValue(propertyName, out accessor); + } + + private static TypePropertyCache GetTypePropertyCache(Type type) + { + if (type == null) + return EmptyTypePropertyCache; + + return TypeCacheMap.GetOrAdd(type, BuildTypePropertyCache); + } + + private static TypePropertyCache BuildTypePropertyCache(Type type) + { + PropertyInfo[] properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + List accessors = new List(properties.Length); + Dictionary propertyMap = new Dictionary(properties.Length, StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < properties.Length; i++) + { + PropertyInfo property = properties[i]; + if (property.GetIndexParameters().Length != 0) + continue; + + PropertyAccessor accessor = new PropertyAccessor(property.Name, property.PropertyType, CreatePropertyGetter(type, property)); + accessors.Add(accessor); + propertyMap.Add(accessor.Name, accessor); + } + + return new TypePropertyCache(accessors.ToArray(), propertyMap); + } + + private static Func CreatePropertyGetter(Type declaringType, PropertyInfo propertyInfo) + { + if (propertyInfo == null || propertyInfo.GetMethod == null) + return _ => null; + + try + { + ParameterExpression instanceParam = Expression.Parameter(typeof(object), "instance"); + UnaryExpression typedInstance = Expression.Convert(instanceParam, declaringType); + MemberExpression propertyAccess = Expression.Property(typedInstance, propertyInfo); + UnaryExpression boxResult = Expression.Convert(propertyAccess, typeof(object)); + return Expression.Lambda>(boxResult, instanceParam).Compile(); + } + catch + { + return obj => propertyInfo.GetValue(obj, null); + } + } + } + + internal sealed class PropertyAccessor + { + public PropertyAccessor(string name, Type propertyType, Func getter) + { + Name = name; + PropertyType = propertyType ?? typeof(object); + Getter = getter ?? (_ => null); + } + + public string Name { get; } + public Type PropertyType { get; } + public Func Getter { get; } + } + + internal sealed class TypePropertyCache + { + public TypePropertyCache(PropertyAccessor[] accessors, Dictionary propertyMap) + { + Accessors = accessors ?? Array.Empty(); + PropertyMap = propertyMap ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public PropertyAccessor[] Accessors { get; } + public Dictionary PropertyMap { get; } + } +} diff --git a/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs b/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs index 7ba59df..282a265 100644 --- a/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs +++ b/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs @@ -4,5 +4,7 @@ internal class ReplaceCode { public string ReplaceRef { get; set; } public string ReplaceCommand { get; set; } + public string TargetPropertyName { get; set; } + public string FormattingCommand { get; set; } } -} \ No newline at end of file +} From 88218f7ab3d5fc807ecdc777ff315fcf940518f3 Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Fri, 6 Mar 2026 23:31:06 +0300 Subject: [PATCH 3/7] chore: Real life scenario tests --- .../MoqModels/OrderEmailModel.cs | 39 +++++ .../RealLifeTemplateScenarioTests.cs | 156 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs create mode 100644 ObjectSemantics.NET.Tests/RealLifeTemplateScenarioTests.cs diff --git a/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs b/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs new file mode 100644 index 0000000..8ce9e2a --- /dev/null +++ b/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace ObjectSemantics.NET.Tests.MoqModels +{ + public class OrderEmailModel + { + public string OrderNo { get; set; } + public DateTime OrderDate { get; set; } + public decimal Subtotal { get; set; } + public decimal Tax { get; set; } + public decimal Total { get; set; } + public bool IsPaid { get; set; } + public OrderCustomer Customer { get; set; } + public List Items { get; set; } = new List(); + } + + public class OrderCustomer + { + public string FullName { get; set; } + public string Email { get; set; } + public OrderAddress BillingAddress { get; set; } + } + + public class OrderAddress + { + public string Line1 { get; set; } + public string City { get; set; } + public string Country { get; set; } + } + + public class OrderLineItem + { + public string Name { get; set; } + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal LineTotal { get; set; } + } +} diff --git a/ObjectSemantics.NET.Tests/RealLifeTemplateScenarioTests.cs b/ObjectSemantics.NET.Tests/RealLifeTemplateScenarioTests.cs new file mode 100644 index 0000000..03676a5 --- /dev/null +++ b/ObjectSemantics.NET.Tests/RealLifeTemplateScenarioTests.cs @@ -0,0 +1,156 @@ +using ObjectSemantics.NET.Tests.MoqModels; +using System; +using System.Collections.Generic; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class RealLifeTemplateScenarioTests + { + [Fact] + public void Should_Render_Order_Confirmation_Email_With_Mixed_Features() + { + OrderEmailModel order = new OrderEmailModel + { + OrderNo = "ORD-1001", + OrderDate = new DateTime(2026, 03, 06, 9, 30, 0), + Subtotal = 2500, + Tax = 400, + Total = 2900, + IsPaid = true, + Customer = new OrderCustomer + { + FullName = "jane doe", + BillingAddress = new OrderAddress + { + Line1 = "12 River Road", + City = "Nairobi", + Country = "Kenya" + } + }, + Items = new List + { + new OrderLineItem { Name = "Keyboard", Quantity = 1, UnitPrice = 1200, LineTotal = 1200 }, + new OrderLineItem { Name = "Mouse", Quantity = 2, UnitPrice = 650, LineTotal = 1300 } + } + }; + + var extra = new Dictionary + { + ["SupportEmail"] = "support@crudsoft.com" + }; + + string template = "Order {{ OrderNo }}|Date {{ OrderDate:yyyy-MM-dd }}|Customer {{ Customer.FullName:titlecase }}|{{ #if(IsPaid == true) }}PAID{{ #else }}UNPAID{{ #endif }}|Items {{ #foreach(Items) }}[{{ Quantity }}x{{ Name }}={{ LineTotal:N2 }}]{{ #endforeach }}|Ship {{ Customer.BillingAddress.City }},{{ Customer.BillingAddress.Country }}|Total {{ Total:N2 }}|Support {{ SupportEmail }}"; + + string result = order.Map(template, extra); + string expected = "Order ORD-1001|Date 2026-03-06|Customer Jane Doe|PAID|Items [1xKeyboard=1,200.00][2xMouse=1,300.00]|Ship Nairobi,Kenya|Total 2,900.00|Support support@crudsoft.com"; + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_Evaluate_Nested_If_Condition_For_Message_Routing() + { + OrderEmailModel order = new OrderEmailModel + { + Customer = new OrderCustomer + { + BillingAddress = new OrderAddress + { + Country = "Kenya" + } + } + }; + + string template = "{{ #if(Customer.BillingAddress.Country == Kenya) }}LOCAL{{ #else }}INTERNATIONAL{{ #endif }}"; + string result = order.Map(template); + + Assert.Equal("LOCAL", result); + } + + [Fact] + public void Should_Fallback_To_Else_When_Intermediate_Nested_Object_Is_Null() + { + OrderEmailModel order = new OrderEmailModel + { + Customer = new OrderCustomer + { + BillingAddress = null + } + }; + + string template = "{{ #if(Customer.BillingAddress.Country == Kenya) }}LOCAL{{ #else }}INTERNATIONAL{{ #endif }}"; + string result = order.Map(template); + + Assert.Equal("INTERNATIONAL", result); + } + + [Fact] + public void Should_Map_Additional_Parameters_With_Dot_Notation() + { + OrderEmailModel order = new OrderEmailModel + { + OrderNo = "ORD-9999" + }; + + var extra = new Dictionary + { + ["Meta.UnsubscribeUrl"] = "https://example.com/unsub/abc123", + ["Meta.Channel"] = "EMAIL" + }; + + string template = "Order {{ OrderNo }} via {{ Meta.Channel }}; Unsubscribe: {{ Meta.UnsubscribeUrl }}"; + string result = order.Map(template, extra); + + Assert.Equal("Order ORD-9999 via EMAIL; Unsubscribe: https://example.com/unsub/abc123", result); + } + + [Fact] + public void Should_Escape_Xml_In_Nested_And_Additional_Values_For_Email() + { + OrderEmailModel order = new OrderEmailModel + { + Customer = new OrderCustomer + { + FullName = "Tom & Jerry " + } + }; + + var extra = new Dictionary + { + ["SupportLink"] = "Help" + }; + + string template = "{{ Customer.FullName }}|{{ SupportLink }}"; + string result = order.Map(template, extra, new TemplateMapperOptions + { + XmlCharEscaping = true + }); + + Assert.Equal("Tom & Jerry <Ltd>|<a href='https://example.com/help'>Help</a>", result); + } + + [Fact] + public void Should_Remain_Deterministic_Across_Repeated_Message_Mapping() + { + string template = "Hello {{ Customer.FullName }} - {{ OrderNo }}"; + + for (int i = 1; i <= 200; i++) + { + OrderEmailModel order = new OrderEmailModel + { + OrderNo = "ORD-" + i.ToString("000", System.Globalization.CultureInfo.InvariantCulture), + Customer = new OrderCustomer + { + FullName = "Customer " + i.ToString(System.Globalization.CultureInfo.InvariantCulture) + } + }; + + string result = order.Map(template); + string expected = "Hello Customer " + i.ToString(System.Globalization.CultureInfo.InvariantCulture) + " - ORD-" + i.ToString("000", System.Globalization.CultureInfo.InvariantCulture); + + Assert.Equal(expected, result); + } + } + } +} From 2a709ef9b476f3f5b4a8cbbc0168c95ad4d13286 Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Fri, 6 Mar 2026 23:36:42 +0300 Subject: [PATCH 4/7] chore: more real-life unit tests scenarios --- .../PromotionAndMessagingTemplateTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 ObjectSemantics.NET.Tests/PromotionAndMessagingTemplateTests.cs diff --git a/ObjectSemantics.NET.Tests/PromotionAndMessagingTemplateTests.cs b/ObjectSemantics.NET.Tests/PromotionAndMessagingTemplateTests.cs new file mode 100644 index 0000000..8ad30a8 --- /dev/null +++ b/ObjectSemantics.NET.Tests/PromotionAndMessagingTemplateTests.cs @@ -0,0 +1,100 @@ +using ObjectSemantics.NET.Tests.MoqModels; +using System.Collections.Generic; +using System.Globalization; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class PromotionAndMessagingTemplateTests + { + [Theory] + [InlineData("A", "Special offer for Jane Doe")] + [InlineData("B", "Do not miss out Jane Doe")] + public void Should_Render_Ab_Test_Subject_Line(string campaignVariant, string expected) + { + OrderEmailModel order = new OrderEmailModel + { + Customer = new OrderCustomer + { + FullName = "jane doe" + } + }; + + var extra = new Dictionary + { + ["CampaignVariant"] = campaignVariant + }; + + string template = "{{ #if(CampaignVariant == A) }}Special offer for {{ Customer.FullName:titlecase }}{{ #else }}Do not miss out {{ Customer.FullName:titlecase }}{{ #endif }}"; + string result = order.Map(template, extra); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_Use_Generic_Greeting_When_Personalized_Name_Is_Null() + { + OrderEmailModel order = new OrderEmailModel + { + Customer = new OrderCustomer + { + FullName = null + } + }; + + string template = "{{ #if(Customer.FullName == null) }}Hello Customer{{ #else }}Hello {{ Customer.FullName:titlecase }}{{ #endif }}"; + string result = order.Map(template); + + Assert.Equal("Hello Customer", result); + } + + [Theory] + [InlineData("EMAIL", "Compliance: Unsubscribe via https://example.com/unsub/u-100")] + [InlineData("SMS", "Compliance: Reply STOP to unsubscribe")] + public void Should_Render_Channel_Specific_Compliance_Block(string channel, string expected) + { + OrderEmailModel order = new OrderEmailModel(); + var extra = new Dictionary + { + ["Meta.Channel"] = channel, + ["Meta.UnsubscribeUrl"] = "https://example.com/unsub/u-100" + }; + + string template = "Compliance: {{ #if(Meta.Channel == EMAIL) }}Unsubscribe via {{ Meta.UnsubscribeUrl }}{{ #else }}Reply STOP to unsubscribe{{ #endif }}"; + string result = order.Map(template, extra); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_Map_Bulk_Message_Batch_Deterministically() + { + string template = "{{ #if(CampaignVariant == A) }}[A]{{ #else }}[B]{{ #endif }} {{ #if(Customer.FullName == null) }}Hello Customer{{ #else }}Hello {{ Customer.FullName:titlecase }}{{ #endif }} - {{ OrderNo }}"; + + for (int i = 1; i <= 250; i++) + { + OrderEmailModel order = new OrderEmailModel + { + OrderNo = "ORD-" + i.ToString("000", CultureInfo.InvariantCulture), + Customer = new OrderCustomer + { + FullName = i % 5 == 0 ? null : "customer " + i.ToString(CultureInfo.InvariantCulture) + } + }; + + var extra = new Dictionary + { + ["CampaignVariant"] = i % 2 == 0 ? "A" : "B" + }; + + string result = order.Map(template, extra); + string expectedPrefix = i % 2 == 0 ? "[A]" : "[B]"; + string expectedName = i % 5 == 0 ? "Hello Customer" : "Hello Customer " + i.ToString(CultureInfo.InvariantCulture); + string expected = expectedPrefix + " " + expectedName + " - ORD-" + i.ToString("000", CultureInfo.InvariantCulture); + + Assert.Equal(expected, result); + Assert.DoesNotContain("{{", result); + } + } + } +} From 1e891391da001f53761db6da087f2105616b3b4c Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Sat, 7 Mar 2026 00:21:54 +0300 Subject: [PATCH 5/7] chore: mathematical calculations support --- .../ExpressionFunctionTests.cs | 226 ++++++ .../MoqModels/OrderEmailModel.cs | 9 + .../Engine/EngineExpressionEvaluator.cs | 685 ++++++++++++++++++ .../Engine/EngineTemplateRenderer.cs | 8 + 4 files changed, 928 insertions(+) create mode 100644 ObjectSemantics.NET.Tests/ExpressionFunctionTests.cs create mode 100644 ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs diff --git a/ObjectSemantics.NET.Tests/ExpressionFunctionTests.cs b/ObjectSemantics.NET.Tests/ExpressionFunctionTests.cs new file mode 100644 index 0000000..de1f713 --- /dev/null +++ b/ObjectSemantics.NET.Tests/ExpressionFunctionTests.cs @@ -0,0 +1,226 @@ +using ObjectSemantics.NET.Tests.MoqModels; +using System.Collections.Generic; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class ExpressionFunctionTests + { + [Fact] + public void Should_Sum_Collection_Path() + { + OrderEmailModel model = new OrderEmailModel + { + Customer = new OrderCustomer + { + Payments = new List + { + new OrderPayment { Amount = 1000 }, + new OrderPayment { Amount = 2000.75m }, + new OrderPayment { Amount = 999.25m } + } + } + }; + + string result = model.Map("{{ __sum(Customer.Payments.Amount):N2 }}"); + Assert.Equal("4,000.00", result); + } + + [Fact] + public void Should_Average_Collection_Path_With_Single_Underscore() + { + OrderEmailModel model = new OrderEmailModel + { + Customer = new OrderCustomer + { + Payments = new List + { + new OrderPayment { PaidAmount = 300 }, + new OrderPayment { PaidAmount = 900 }, + new OrderPayment { PaidAmount = 1200 } + } + } + }; + + string result = model.Map("{{ _avg(Customer.Payments.PaidAmount):N2 }}"); + Assert.Equal("800.00", result); + } + + [Fact] + public void Should_Calculate_Property_Expression() + { + OrderEmailModel model = new OrderEmailModel + { + PaidAmount = 10000, + Customer = new OrderCustomer + { + CreditLimit = 4500 + } + }; + + string result = model.Map("{{ __calc(PaidAmount - Customer.CreditLimit):N2 }}"); + Assert.Equal("5,500.00", result); + } + + [Fact] + public void Should_Calculate_Expression_Including_Parentheses_And_Decimals() + { + OrderEmailModel model = new OrderEmailModel + { + Subtotal = 1000, + Tax = 160 + }; + + string result = model.Map("{{ __calc((Subtotal + Tax) * 0.5):N2 }}"); + Assert.Equal("580.00", result); + } + + [Fact] + public void Should_Calculate_Count_Min_And_Max_Aggregates() + { + OrderEmailModel model = new OrderEmailModel + { + Customer = new OrderCustomer + { + Payments = new List + { + new OrderPayment { Amount = 700 }, + new OrderPayment { Amount = 200 }, + new OrderPayment { Amount = 1100 } + } + } + }; + + string template = "{{ __count(Customer.Payments.Amount) }}|{{ __min(Customer.Payments.Amount):N2 }}|{{ __max(Customer.Payments.Amount):N2 }}"; + string result = model.Map(template); + Assert.Equal("3|200.00|1,100.00", result); + } + + [Fact] + public void Should_Calculate_Per_Row_Expression_Inside_Loop() + { + OrderEmailModel model = new OrderEmailModel + { + Items = new List + { + new OrderLineItem { Quantity = 2, UnitPrice = 350 }, + new OrderLineItem { Quantity = 3, UnitPrice = 100.5m } + } + }; + + string template = "{{ #foreach(Items) }}[{{ __calc(Quantity * UnitPrice):N2 }}]{{ #endforeach }}"; + string result = model.Map(template); + + Assert.Equal("[700.00][301.50]", result); + } + + [Fact] + public void Should_Render_Empty_When_Calc_Expression_Is_Invalid() + { + OrderEmailModel model = new OrderEmailModel + { + PaidAmount = 5000, + Customer = new OrderCustomer + { + CreditLimit = 1000 + } + }; + + string result = model.Map("A{{ __calc(PaidAmount - UnknownValue):N2 }}B"); + Assert.Equal("AB", result); + } + + [Fact] + public void Should_Render_Empty_For_Unknown_Aggregate_Path() + { + OrderEmailModel model = new OrderEmailModel + { + Customer = new OrderCustomer + { + Payments = new List + { + new OrderPayment { Amount = 100 } + } + } + }; + + string result = model.Map("{{ __sum(Customer.UnknownPayments.Amount) }}|{{ __avg(Customer.UnknownPayments.Amount) }}|{{ __count(Customer.UnknownPayments.Amount) }}|{{ __min(Customer.UnknownPayments.Amount) }}|{{ __max(Customer.UnknownPayments.Amount) }}"); + Assert.Equal("||||", result); + } + + [Fact] + public void Should_Return_Zero_For_Aggregates_When_Source_Is_Null() + { + OrderEmailModel model = new OrderEmailModel + { + Customer = null + }; + + string result = model.Map("{{ __sum(Customer.Payments.Amount) }}|{{ __avg(Customer.Payments.Amount) }}|{{ __count(Customer.Payments.Amount) }}|{{ __min(Customer.Payments.Amount) }}|{{ __max(Customer.Payments.Amount) }}"); + Assert.Equal("0|0|0|0|0", result); + } + + [Fact] + public void Should_Return_Zero_When_Calc_Uses_Null_Operand_Path() + { + OrderEmailModel model = new OrderEmailModel + { + PaidAmount = 5000, + Customer = null + }; + + string result = model.Map("A{{ __calc(PaidAmount - Customer.CreditLimit) }}B"); + Assert.Equal("A0B", result); + } + + [Fact] + public void Should_Support_Expressions_Without_Number_Format() + { + OrderEmailModel model = new OrderEmailModel + { + PaidAmount = 10000, + Customer = new OrderCustomer + { + CreditLimit = 4500, + Payments = new List + { + new OrderPayment { Amount = 1000 }, + new OrderPayment { Amount = 2000 }, + new OrderPayment { Amount = 1500 } + } + } + }; + + string result = model.Map("{{ __sum(Customer.Payments.Amount) }}|{{ __calc(PaidAmount - Customer.CreditLimit) }}"); + Assert.Equal("4500|5500", result); + } + + [Fact] + public void Should_Render_Empty_When_Sum_Path_Is_String_Based() + { + OrderEmailModel model = new OrderEmailModel + { + Items = new List + { + new OrderLineItem { Name = "Keyboard" }, + new OrderLineItem { Name = "Mouse" } + } + }; + + string result = model.Map("X{{ __sum(Items.Name) }}Y"); + Assert.Equal("XY", result); + } + + [Fact] + public void Should_Render_Empty_When_Sum_Path_Is_Date_Based() + { + OrderEmailModel model = new OrderEmailModel + { + OrderDate = new System.DateTime(2026, 03, 07) + }; + + string result = model.Map("X{{ __sum(OrderDate) }}Y"); + Assert.Equal("XY", result); + } + } +} diff --git a/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs b/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs index 8ce9e2a..2d0648c 100644 --- a/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs +++ b/ObjectSemantics.NET.Tests/MoqModels/OrderEmailModel.cs @@ -10,6 +10,7 @@ public class OrderEmailModel public decimal Subtotal { get; set; } public decimal Tax { get; set; } public decimal Total { get; set; } + public decimal PaidAmount { get; set; } public bool IsPaid { get; set; } public OrderCustomer Customer { get; set; } public List Items { get; set; } = new List(); @@ -19,6 +20,8 @@ public class OrderCustomer { public string FullName { get; set; } public string Email { get; set; } + public decimal CreditLimit { get; set; } + public List Payments { get; set; } = new List(); public OrderAddress BillingAddress { get; set; } } @@ -36,4 +39,10 @@ public class OrderLineItem public decimal UnitPrice { get; set; } public decimal LineTotal { get; set; } } + + public class OrderPayment + { + public decimal Amount { get; set; } + public decimal PaidAmount { get; set; } + } } diff --git a/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs b/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs new file mode 100644 index 0000000..b67c15d --- /dev/null +++ b/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs @@ -0,0 +1,685 @@ +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ObjectSemantics.NET.Engine +{ + internal static class EngineExpressionEvaluator + { + private static readonly Regex FunctionRegex = new Regex(@"^\s*_*(?sum|avg|count|min|max|calc)\s*\(\s*(?.*)\s*\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static bool TryEvaluate(string expressionCommand, object rootRecord, Dictionary propMap, out ExtractedObjProperty evaluatedProperty, out bool renderEmptyOnFailure, out bool isExpressionCommand) + { + evaluatedProperty = null; + renderEmptyOnFailure = false; + isExpressionCommand = false; + if (string.IsNullOrWhiteSpace(expressionCommand)) + return false; + + Match match = FunctionRegex.Match(expressionCommand.Trim()); + if (!match.Success) + return false; + isExpressionCommand = true; + + string fn = match.Groups["fn"].Value.Trim().ToLowerInvariant(); + string arg = match.Groups["arg"].Value.Trim(); + + switch (fn) + { + case "sum": + if (!TryAggregateNumeric(arg, rootRecord, propMap, AggregateMode.Sum, out decimal sum)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, sum); + return true; + + case "avg": + if (!TryAggregateNumeric(arg, rootRecord, propMap, AggregateMode.Average, out decimal avg)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, avg); + return true; + + case "count": + if (!TryCount(arg, rootRecord, propMap, out int count)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = new ExtractedObjProperty + { + Name = expressionCommand, + Type = typeof(int), + OriginalValue = count + }; + return true; + + case "min": + if (!TryAggregateNumeric(arg, rootRecord, propMap, AggregateMode.Min, out decimal min)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, min); + return true; + + case "max": + if (!TryAggregateNumeric(arg, rootRecord, propMap, AggregateMode.Max, out decimal max)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, max); + return true; + + case "calc": + if (!TryEvaluateArithmetic(arg, rootRecord, propMap, out decimal calcResult)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, calcResult); + return true; + } + + return false; + } + + private static ExtractedObjProperty CreateDecimalProperty(string name, decimal value) + { + return new ExtractedObjProperty + { + Name = name, + Type = typeof(decimal), + OriginalValue = value + }; + } + + private static bool TryCount(string path, object rootRecord, Dictionary propMap, out int count) + { + count = 0; + if (string.IsNullOrWhiteSpace(path)) + return false; + + List values = ResolvePathValues(path, rootRecord, propMap); + if (values.Count == 0) + return false; + + for (int i = 0; i < values.Count; i++) + { + if (values[i] != null) + count++; + } + return true; + } + + private static bool TryAggregateNumeric(string path, object rootRecord, Dictionary propMap, AggregateMode mode, out decimal result) + { + result = 0m; + if (string.IsNullOrWhiteSpace(path)) + return false; + + List values = ResolvePathValues(path, rootRecord, propMap); + + if (values.Count == 0) + return false; + + bool hasAny = false; + bool hasInvalidNonNumeric = false; + decimal running = 0m; + int numericCount = 0; + + for (int i = 0; i < values.Count; i++) + { + object rawValue = values[i]; + if (rawValue == null) + continue; + + if (!TryConvertToDecimal(rawValue, out decimal numeric)) + { + hasInvalidNonNumeric = true; + continue; + } + + if (!hasAny) + { + running = numeric; + hasAny = true; + } + else + { + switch (mode) + { + case AggregateMode.Sum: + case AggregateMode.Average: + running += numeric; + break; + case AggregateMode.Min: + if (numeric < running) running = numeric; + break; + case AggregateMode.Max: + if (numeric > running) running = numeric; + break; + } + } + + numericCount++; + } + + if (hasInvalidNonNumeric) + return false; + + if (!hasAny) + { + result = 0m; + return true; + } + + if (mode == AggregateMode.Average) + result = numericCount == 0 ? 0m : running / numericCount; + else + result = running; + + return true; + } + + private static List ResolvePathValues(string path, object rootRecord, Dictionary propMap) + { + List empty = new List(); + if (string.IsNullOrWhiteSpace(path) || propMap == null) + return empty; + + string normalizedPath = path.Trim(); + if (propMap.TryGetValue(normalizedPath, out ExtractedObjProperty directProperty)) + return new List { directProperty?.OriginalValue }; + + int dotIndex = normalizedPath.IndexOf('.'); + string rootName = dotIndex >= 0 ? normalizedPath.Substring(0, dotIndex).Trim() : normalizedPath; + string nestedPath = dotIndex >= 0 ? normalizedPath.Substring(dotIndex + 1).Trim() : string.Empty; + + if (!propMap.TryGetValue(rootName, out ExtractedObjProperty rootProperty)) + return empty; + + if (string.IsNullOrEmpty(nestedPath)) + return new List { rootProperty?.OriginalValue }; + + string[] segments = SplitPathSegments(nestedPath); + if (segments.Length == 0) + return new List { rootProperty?.OriginalValue }; + + List currentValues = new List { rootProperty?.OriginalValue }; + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i]; + List nextValues = new List(); + + for (int j = 0; j < currentValues.Count; j++) + ExpandSegmentValues(currentValues[j], segment, nextValues); + + currentValues = nextValues; + if (currentValues.Count == 0) + break; + } + + return currentValues; + } + + private static void ExpandSegmentValues(object current, string segment, List nextValues) + { + if (string.IsNullOrWhiteSpace(segment)) + return; + + if (current == null) + { + nextValues.Add(null); + return; + } + + if (current is IEnumerable enumerable && !(current is string)) + { + foreach (object item in enumerable) + ExpandSegmentValues(item, segment, nextValues); + return; + } + + Type type = current.GetType(); + if (!EngineTypeMetadataCache.TryGetPropertyAccessor(type, segment, out PropertyAccessor accessor)) + return; + + nextValues.Add(accessor.Getter(current)); + } + + private static string[] SplitPathSegments(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return Array.Empty(); + + string[] raw = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + if (raw.Length == 0) + return raw; + + int count = 0; + for (int i = 0; i < raw.Length; i++) + { + string trimmed = raw[i].Trim(); + if (trimmed.Length == 0) + continue; + raw[count] = trimmed; + count++; + } + + if (count == raw.Length) + return raw; + + string[] trimmedSegments = new string[count]; + Array.Copy(raw, trimmedSegments, count); + return trimmedSegments; + } + + private static bool TryConvertToDecimal(object value, out decimal number) + { + number = 0m; + if (value == null) + return false; + + switch (value) + { + case decimal d: + number = d; + return true; + case int i: + number = i; + return true; + case long l: + number = l; + return true; + case short s: + number = s; + return true; + case byte b: + number = b; + return true; + case double db: + number = Convert.ToDecimal(db, CultureInfo.InvariantCulture); + return true; + case float f: + number = Convert.ToDecimal(f, CultureInfo.InvariantCulture); + return true; + case string str: + return decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out number); + } + + if (value is IConvertible convertible) + { + try + { + number = convertible.ToDecimal(CultureInfo.InvariantCulture); + return true; + } + catch + { + return false; + } + } + + return false; + } + + private static bool TryEvaluateArithmetic(string expression, object rootRecord, Dictionary propMap, out decimal result) + { + result = 0m; + if (string.IsNullOrWhiteSpace(expression)) + return false; + + if (!TryTokenize(expression, out List tokens)) + return false; + if (!TryToRpn(tokens, out List rpn)) + return false; + + Stack stack = new Stack(); + bool hasNullOperand = false; + for (int i = 0; i < rpn.Count; i++) + { + ExpressionToken token = rpn[i]; + switch (token.Kind) + { + case ExpressionTokenKind.Number: + stack.Push(token.NumberValue); + break; + case ExpressionTokenKind.Identifier: + IdentifierResolveMode identifierResolveMode = TryResolveIdentifierToNumber(token.TextValue, rootRecord, propMap, out decimal identifierValue); + if (identifierResolveMode == IdentifierResolveMode.UnknownPath || identifierResolveMode == IdentifierResolveMode.NonNumeric || identifierResolveMode == IdentifierResolveMode.Ambiguous) + return false; + + if (identifierResolveMode == IdentifierResolveMode.NullValue) + hasNullOperand = true; + + stack.Push(identifierResolveMode == IdentifierResolveMode.NullValue ? 0m : identifierValue); + break; + case ExpressionTokenKind.UnaryMinus: + if (stack.Count < 1) + return false; + stack.Push(-stack.Pop()); + break; + case ExpressionTokenKind.Operator: + if (stack.Count < 2) + return false; + decimal right = stack.Pop(); + decimal left = stack.Pop(); + switch (token.OperatorChar) + { + case '+': + stack.Push(left + right); + break; + case '-': + stack.Push(left - right); + break; + case '*': + stack.Push(left * right); + break; + case '/': + if (right == 0m) + return false; + stack.Push(left / right); + break; + default: + return false; + } + break; + default: + return false; + } + } + + if (stack.Count != 1) + return false; + + decimal computed = stack.Pop(); + result = hasNullOperand ? 0m : computed; + return true; + } + + private static IdentifierResolveMode TryResolveIdentifierToNumber(string identifier, object rootRecord, Dictionary propMap, out decimal number) + { + number = 0m; + if (string.IsNullOrWhiteSpace(identifier)) + return IdentifierResolveMode.UnknownPath; + + List values = ResolvePathValues(identifier, rootRecord, propMap); + if (values.Count == 0) + return IdentifierResolveMode.UnknownPath; + + if (values.Count > 1) + return IdentifierResolveMode.Ambiguous; + + object singleValue = values[0]; + if (singleValue == null) + return IdentifierResolveMode.NullValue; + + if (!TryConvertToDecimal(singleValue, out decimal parsedValue)) + return IdentifierResolveMode.NonNumeric; + + number = parsedValue; + return IdentifierResolveMode.Success; + } + + private static bool TryTokenize(string expression, out List tokens) + { + tokens = new List(); + int i = 0; + + while (i < expression.Length) + { + char ch = expression[i]; + if (char.IsWhiteSpace(ch)) + { + i++; + continue; + } + + if (char.IsDigit(ch) || (ch == '.' && i + 1 < expression.Length && char.IsDigit(expression[i + 1]))) + { + int start = i; + bool seenDot = false; + while (i < expression.Length) + { + char c = expression[i]; + if (char.IsDigit(c)) + { + i++; + continue; + } + + if (c == '.' && !seenDot) + { + seenDot = true; + i++; + continue; + } + + break; + } + + string numberText = expression.Substring(start, i - start); + if (!decimal.TryParse(numberText, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal parsedNumber)) + return false; + + tokens.Add(ExpressionToken.Number(parsedNumber)); + continue; + } + + if (char.IsLetter(ch) || ch == '_') + { + int start = i; + while (i < expression.Length) + { + char c = expression[i]; + if (char.IsLetterOrDigit(c) || c == '_' || c == '.') + i++; + else + break; + } + + string identifier = expression.Substring(start, i - start).Trim(); + if (identifier.Length == 0) + return false; + + tokens.Add(ExpressionToken.Identifier(identifier)); + continue; + } + + if (ch == '+' || ch == '-' || ch == '*' || ch == '/') + { + tokens.Add(ExpressionToken.Operator(ch)); + i++; + continue; + } + + if (ch == '(') + { + tokens.Add(ExpressionToken.LeftParenthesis()); + i++; + continue; + } + + if (ch == ')') + { + tokens.Add(ExpressionToken.RightParenthesis()); + i++; + continue; + } + + return false; + } + + return tokens.Count > 0; + } + + private static bool TryToRpn(List tokens, out List rpn) + { + rpn = new List(tokens.Count); + Stack operators = new Stack(); + ExpressionToken previousToken = default; + bool hasPrevious = false; + + for (int i = 0; i < tokens.Count; i++) + { + ExpressionToken token = tokens[i]; + switch (token.Kind) + { + case ExpressionTokenKind.Number: + case ExpressionTokenKind.Identifier: + rpn.Add(token); + break; + case ExpressionTokenKind.Operator: + bool isUnary = token.OperatorChar == '-' && (!hasPrevious || previousToken.Kind == ExpressionTokenKind.Operator || previousToken.Kind == ExpressionTokenKind.UnaryMinus || previousToken.Kind == ExpressionTokenKind.LeftParenthesis); + + ExpressionToken currentOperator = isUnary ? ExpressionToken.UnaryMinus() : token; + + while (operators.Count > 0 && IsOperatorToken(operators.Peek())) + { + ExpressionToken top = operators.Peek(); + int currentPrecedence = GetPrecedence(currentOperator); + int topPrecedence = GetPrecedence(top); + bool currentRightAssociative = currentOperator.Kind == ExpressionTokenKind.UnaryMinus; + + if ((!currentRightAssociative && currentPrecedence <= topPrecedence) || + (currentRightAssociative && currentPrecedence < topPrecedence)) + { + rpn.Add(operators.Pop()); + continue; + } + + break; + } + + operators.Push(currentOperator); + break; + case ExpressionTokenKind.LeftParenthesis: + operators.Push(token); + break; + case ExpressionTokenKind.RightParenthesis: + bool foundLeft = false; + while (operators.Count > 0) + { + ExpressionToken top = operators.Pop(); + if (top.Kind == ExpressionTokenKind.LeftParenthesis) + { + foundLeft = true; + break; + } + + rpn.Add(top); + } + + if (!foundLeft) + return false; + break; + default: + return false; + } + + previousToken = token; + hasPrevious = true; + } + + while (operators.Count > 0) + { + ExpressionToken top = operators.Pop(); + if (top.Kind == ExpressionTokenKind.LeftParenthesis || top.Kind == ExpressionTokenKind.RightParenthesis) + return false; + + rpn.Add(top); + } + + return rpn.Count > 0; + } + + private static bool IsOperatorToken(ExpressionToken token) + { + return token.Kind == ExpressionTokenKind.Operator || token.Kind == ExpressionTokenKind.UnaryMinus; + } + + private static int GetPrecedence(ExpressionToken token) + { + if (token.Kind == ExpressionTokenKind.UnaryMinus) + return 3; + if (token.Kind != ExpressionTokenKind.Operator) + return 0; + + return token.OperatorChar == '*' || token.OperatorChar == '/' ? 2 : 1; + } + + private enum IdentifierResolveMode + { + Success, + UnknownPath, + NullValue, + NonNumeric, + Ambiguous + } + + private enum AggregateMode + { + Sum, + Average, + Min, + Max + } + + private enum ExpressionTokenKind + { + Number, + Identifier, + Operator, + LeftParenthesis, + RightParenthesis, + UnaryMinus + } + + private struct ExpressionToken + { + public ExpressionTokenKind Kind; + public decimal NumberValue; + public string TextValue; + public char OperatorChar; + + public static ExpressionToken Number(decimal value) + { + return new ExpressionToken { Kind = ExpressionTokenKind.Number, NumberValue = value }; + } + + public static ExpressionToken Identifier(string value) + { + return new ExpressionToken { Kind = ExpressionTokenKind.Identifier, TextValue = value }; + } + + public static ExpressionToken Operator(char op) + { + return new ExpressionToken { Kind = ExpressionTokenKind.Operator, OperatorChar = op }; + } + + public static ExpressionToken LeftParenthesis() + { + return new ExpressionToken { Kind = ExpressionTokenKind.LeftParenthesis }; + } + + public static ExpressionToken RightParenthesis() + { + return new ExpressionToken { Kind = ExpressionTokenKind.RightParenthesis }; + } + + public static ExpressionToken UnaryMinus() + { + return new ExpressionToken { Kind = ExpressionTokenKind.UnaryMinus, OperatorChar = '-' }; + } + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs index 9ec2975..9508349 100644 --- a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs +++ b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs @@ -95,6 +95,10 @@ internal static class EngineTemplateRenderer if (EnginePropertyResolver.TryResolveProperty(rowMap, propName, out ExtractedObjProperty rowProperty)) activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, rowProperty.GetPropertyDisplayString(formattingCommand, options)); + else if (EngineExpressionEvaluator.TryEvaluate(propName, row, rowMap, out ExtractedObjProperty expressionProperty, out bool renderEmptyOnExpressionFailure, out bool isExpressionCommand)) + activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, expressionProperty.GetPropertyDisplayString(formattingCommand, options)); + else if (isExpressionCommand && renderEmptyOnExpressionFailure) + activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, string.Empty); else activeRow.ReplaceFirstOccurrence(objLoopCode.ReplaceRef, objLoopCode.ReplaceCommand); } @@ -115,6 +119,10 @@ internal static class EngineTemplateRenderer if (EnginePropertyResolver.TryResolveProperty(propMap, targetPropertyName, out ExtractedObjProperty property)) result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, property.GetPropertyDisplayString(formattingCommand, options)); + else if (EngineExpressionEvaluator.TryEvaluate(targetPropertyName, record, propMap, out ExtractedObjProperty expressionProperty, out bool renderEmptyOnExpressionFailure, out bool isExpressionCommand)) + result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, expressionProperty.GetPropertyDisplayString(formattingCommand, options)); + else if (isExpressionCommand && renderEmptyOnExpressionFailure) + result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, string.Empty); else result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, "{{ " + replaceCode.ReplaceCommand + " }}"); } From a73f8ae2bd8c5144b71d03baaddd2fe30f2b96b9 Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Sat, 7 Mar 2026 00:30:54 +0300 Subject: [PATCH 6/7] chore: documentation --- README.md | 213 +++++++++++++++------------------------- wiki/Calculations.md | 95 ++++++++++++++++++ wiki/Getting-Started.md | 120 ++++++++++++++++++++++ wiki/Home.md | 59 +++++++++++ wiki/Recipes.md | 74 ++++++++++++++ wiki/Template-Syntax.md | 102 +++++++++++++++++++ wiki/Troubleshooting.md | 65 ++++++++++++ wiki/_Sidebar.md | 8 ++ 8 files changed, 604 insertions(+), 132 deletions(-) create mode 100644 wiki/Calculations.md create mode 100644 wiki/Getting-Started.md create mode 100644 wiki/Home.md create mode 100644 wiki/Recipes.md create mode 100644 wiki/Template-Syntax.md create mode 100644 wiki/Troubleshooting.md create mode 100644 wiki/_Sidebar.md diff --git a/README.md b/README.md index 20318cb..0b2565a 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,107 @@ # ObjectSemantics.NET [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fswagfin%2FObjectSemantics.NET.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fswagfin%2FObjectSemantics.NET?ref=badge_shield) -**Simple and flexible object-to-template string mapper with formatting support** - -## 🧠 Overview - -**ObjectSemantics.NET** is a lightweight C# library that lets you inject object property values directly into string templates much like [Handlebars](https://handlebarsjs.com/) or Helm templates, but focused on .NET. - -This is especially useful when you want to dynamically generate content such as: -- Email templates -- HTML fragments -- Reports or invoices -- Config files -- Logging output ---- - -## 📦 Installation - +Object-to-template mapping for .NET with nested property support, loops, conditions, formatting, and lightweight calculations. + +## Why ObjectSemantics.NET +Use it when you need fast, readable template mapping for: +- Email and SMS templates +- Receipts, invoices, and reports +- Notification payloads +- Config and log rendering + +## Features +- Direct property mapping: `{{ Name }}` +- Nested property mapping: `{{ Customer.BankingDetail.BankName }}` +- Collection loops: `{{ #foreach(Items) }}...{{ #endforeach }}` +- Conditional blocks: `{{ #if(Age >= 18) }}...{{ #else }}...{{ #endif }}` +- Built-in formatting for number/date/string +- XML escaping option (`XmlCharEscaping = true`) +- Calculation functions: + - `sum`, `avg`, `count`, `min`, `max` + - `calc` arithmetic expressions + - Function names accept optional leading underscores: `_sum(...)`, `__calc(...)` + +## Installation Install from [NuGet](https://www.nuget.org/packages/ObjectSemantics.NET): -```bash +```powershell Install-Package ObjectSemantics.NET ``` ---- - -## 🚀 Quick Start - -### Example 1: Mapping Object Properties - -```csharp -Person person = new Person -{ - Name = "John Doe" -}; - -// Define template and map it using the object -string result = person.Map("I am {{ Name }}!"); - -Console.WriteLine(result); -``` - -**Output:** -``` -I am John Doe! -``` ---- - -### Example 2: Mapping Using String Extension - +## Quick Start ```csharp -Person person = new Person -{ - Name = "Jane Doe" -}; +using ObjectSemantics.NET; -// You can also start with the string template -string result = "I am {{ Name }}!".Map(person); - -Console.WriteLine(result); -``` - -**Output:** -``` -I am Jane Doe! +var person = new Person { Name = "John Doe" }; +string output = person.Map("Hello {{ Name }}"); +// Hello John Doe ``` ---- - -### Example 3: Mapping Enumerable Collections (Looping) +## Template Examples +### Nested mapping ```csharp -Person person = new Person +var payment = new CustomerPayment { - MyCars = new List + Amount = 100000000, + Customer = new Customer { - new Car { Make = "BMW", Year = 2023 }, - new Car { Make = "Rolls-Royce", Year = 2020 } + CompanyName = "CRUDSOFT TECHNOLOGIES" } }; -string template = @" -{{ #foreach(MyCars) }} - - {{ Year }} {{ Make }} -{{ #endforeach }}"; - -string result = person.Map(template); - -Console.WriteLine(result); +string result = payment.Map("Paid Amount: {{ Amount:N2 }} By {{ Customer.CompanyName }}"); +// Paid Amount: 100,000,000.00 By CRUDSOFT TECHNOLOGIES ``` -**Output:** -``` - - 2023 BMW - - 2020 Rolls-Royce -``` ---- - -### Example 4: Conditional Logic with `#if`, `#else`, and `#endif` - +### Loop + format ```csharp -Person person = new Person -{ - Age = 40 -}; - -string template = @" -{{ #if(Age >= 18) }} - Adult -{{ #else }} - Minor -{{ #endif }}"; - -string result = person.Map(template); - -Console.WriteLine(result); +string template = "{{ #foreach(Items) }}[{{ Quantity }}x{{ Name }}={{ LineTotal:N2 }}]{{ #endforeach }}"; ``` -**Output:** -``` -Adult -``` ---- -### Example 5: Number Formatting Support - +### Condition ```csharp -Car car = new Car -{ - Price = 50000 -}; - -string result = car.Map("{{ Price:#,##0 }} | {{ Price:N2 }}"); - -Console.WriteLine(result); -``` - -**Output:** +string template = "{{ #if(IsPaid == true) }}PAID{{ #else }}UNPAID{{ #endif }}"; ``` -50,000 | 50,000.00 -``` ---- - -## 💡 More Examples & Documentation - -Explore more usage examples and edge cases in the Wiki Page: -📁 [`Wiki Page`](https://github.com/swagfin/ObjectSemantics.NET/wiki/%F0%9F%9B%A0-Usage-Guide) - ---- - -## 🤝 Contributing - -Feel free to open issues or contribute improvements via pull requests! - ---- - -## 📄 MIT License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fswagfin%2FObjectSemantics.NET.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fswagfin%2FObjectSemantics.NET?ref=badge_large) +### Calculations +```csharp +// Aggregates +"{{ __sum(Customer.Payments.Amount):N2 }}" +"{{ _avg(Customer.Payments.PaidAmount):N2 }}" +"{{ __count(Customer.Payments.Amount) }}" + +// Arithmetic expression +"{{ __calc(PaidAmount - Customer.CreditLimit):N2 }}" +``` + +Calculation behavior: +- Null source/property in math path returns zero +- Unknown property/path returns empty +- Invalid/non-numeric math expression returns empty + +## Documentation +Detailed wiki files are available in-repo: +- [Wiki Home](wiki/Home.md) +- [Getting Started](wiki/Getting-Started.md) +- [Template Syntax](wiki/Template-Syntax.md) +- [Calculations](wiki/Calculations.md) +- [Real-World Recipes](wiki/Recipes.md) +- [Troubleshooting](wiki/Troubleshooting.md) + +These files can be copied directly into your GitHub Wiki repository. + +## Validation +Current test suite covers: +- Nested object mapping +- Conditions and loops +- String/number/date formatting +- File template mapping +- Real-world email and messaging scenarios +- Expression functions and edge cases + +## Contributing +Contributions are welcome through issues and pull requests. + +## License +[MIT](LICENSE) diff --git a/wiki/Calculations.md b/wiki/Calculations.md new file mode 100644 index 0000000..91289ee --- /dev/null +++ b/wiki/Calculations.md @@ -0,0 +1,95 @@ +# Calculations + +ObjectSemantics.NET supports lightweight calculation expressions directly in templates. + +## Supported Functions + +You can use function names with or without leading underscores. + +| Function | Purpose | Example | +| --- | --- | --- | +| `sum(path)` | Sum numeric values in a path | `{{ __sum(Customer.Payments.Amount):N2 }}` | +| `avg(path)` | Average numeric values in a path | `{{ _avg(Customer.Payments.PaidAmount):N2 }}` | +| `count(path)` | Count non-null values in a path | `{{ __count(Customer.Payments.Amount) }}` | +| `min(path)` | Minimum numeric value in a path | `{{ __min(Customer.Payments.Amount):N2 }}` | +| `max(path)` | Maximum numeric value in a path | `{{ __max(Customer.Payments.Amount):N2 }}` | +| `calc(expr)` | Arithmetic over properties/literals | `{{ __calc(PaidAmount - Customer.CreditLimit):N2 }}` | + +## Aggregates + +Example model shape: + +```csharp +Customer.Payments = [ + { Amount = 1000 }, + { Amount = 2000 }, + { Amount = 1500 } +]; +``` + +Examples: + +```text +{{ __sum(Customer.Payments.Amount) }} -> 4500 +{{ __avg(Customer.Payments.Amount):N2 }} -> 1,500.00 +{{ __count(Customer.Payments.Amount) }} -> 3 +{{ __min(Customer.Payments.Amount) }} -> 1000 +{{ __max(Customer.Payments.Amount) }} -> 2000 +``` + +## Arithmetic with `calc` + +Operators: + +- `+` +- `-` +- `*` +- `/` +- Parentheses `(...)` + +Examples: + +```text +{{ __calc(PaidAmount - Customer.CreditLimit):N2 }} +{{ __calc((Subtotal + Tax) * 0.5):N2 }} +{{ __calc(Quantity * UnitPrice) }} +``` + +## Works Inside Loops + +```text +{{ #foreach(Items) }} + [{{ Name }}={{ __calc(Quantity * UnitPrice):N2 }}] +{{ #endforeach }} +``` + +## Behavior Rules + +| Scenario | Result | +| --- | --- | +| Valid numeric expression/path | Numeric output | +| Null source/property in math path | `0` | +| Unknown path/property in expression | Empty | +| Non-numeric data in numeric expression (e.g. string/date for `sum`) | Empty | +| Invalid expression syntax | Empty | + +Notes: + +- These rules apply to `sum`, `avg`, `count`, `min`, `max`, and `calc`. +- Standard placeholder behavior is different: unknown regular properties remain unresolved (`{{ UnknownProp }}`). + +## Formatting Calculated Results + +Calculation results can use normal format specifiers: + +```text +{{ __sum(Customer.Payments.Amount):N2 }} +{{ __calc(PaidAmount - CreditLimit):#,##0 }} +``` + +Or no format at all: + +```text +{{ __sum(Customer.Payments.Amount) }} +{{ __calc(PaidAmount - CreditLimit) }} +``` diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md new file mode 100644 index 0000000..541881d --- /dev/null +++ b/wiki/Getting-Started.md @@ -0,0 +1,120 @@ +# Getting Started + +## 1. Install + +```powershell +Install-Package ObjectSemantics.NET +``` + +## 2. Basic Mapping + +```csharp +using ObjectSemantics.NET; + +public class Person +{ + public string Name { get; set; } + public int Age { get; set; } +} + +var person = new Person { Name = "John", Age = 30 }; +string result = person.Map("Hi {{ Name }}, age {{ Age }}"); +// Hi John, age 30 +``` + +You can also map from the template side: + +```csharp +string result = "Hi {{ Name }}".Map(person); +``` + +## 3. Nested Property Mapping + +```csharp +public class Customer +{ + public string CompanyName { get; set; } +} + +public class Payment +{ + public decimal Amount { get; set; } + public Customer Customer { get; set; } +} + +var payment = new Payment +{ + Amount = 100000000m, + Customer = new Customer { CompanyName = "CRUDSOFT TECHNOLOGIES" } +}; + +string result = payment.Map("Paid {{ Amount:N2 }} by {{ Customer.CompanyName }}"); +// Paid 100,000,000.00 by CRUDSOFT TECHNOLOGIES +``` + +## 4. Additional Parameters + +Pass runtime values that are not on your model: + +```csharp +public class MessageItem +{ + public string Name { get; set; } + public int Qty { get; set; } +} + +public class MessageModel +{ + public string Name { get; set; } + public bool IsPaid { get; set; } + public List Items { get; set; } = new List(); +} + +var model = new MessageModel { Name = "Jane" }; +var extra = new Dictionary +{ + ["AppName"] = "ObjectSemantics.NET", + ["Meta.Channel"] = "EMAIL" +}; + +string result = model.Map("{{ Name }} via {{ Meta.Channel }} in {{ AppName }}", extra); +``` + +## 5. XML Escaping Option + +Use `TemplateMapperOptions` when rendering XML-safe output: + +```csharp +var model = new MessageModel { Name = "Tom & Jerry " }; +string result = model.Map("{{ Name }}", null, new TemplateMapperOptions +{ + XmlCharEscaping = true +}); +// Tom & Jerry <Ltd> +``` + +## 6. First Loop and Condition + +```csharp +var model = new MessageModel +{ + Name = "John", + IsPaid = true, + Items = new List + { + new MessageItem { Name = "Keyboard", Qty = 1 }, + new MessageItem { Name = "Mouse", Qty = 2 } + } +}; + +string template = @"Status: {{ #if(IsPaid == true) }}PAID{{ #else }}UNPAID{{ #endif }} +{{ #foreach(Items) }}- {{ Qty }} x {{ Name }} +{{ #endforeach }}"; + +string result = model.Map(template); +``` + +## Next + +- Continue with [Template Syntax](Template-Syntax.md) +- Learn expression functions in [Calculations](Calculations.md) diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..838bd69 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,59 @@ +# ObjectSemantics.NET Wiki + +Fast, readable object-to-template mapping for .NET applications. + +ObjectSemantics.NET helps you build email, SMS, receipt, invoice, and notification templates from your domain objects with minimal code. + +## Start Here + +| Guide | Purpose | +| --- | --- | +| [Getting Started](Getting-Started.md) | Install, map first template, use options | +| [Template Syntax](Template-Syntax.md) | Placeholders, nested paths, loops, if/else, formatting | +| [Calculations](Calculations.md) | `sum`, `avg`, `count`, `min`, `max`, `calc` | +| [Recipes](Recipes.md) | Real-world email/message patterns | +| [Troubleshooting](Troubleshooting.md) | Fix unresolved values, empty outputs, formatting issues | + +## Quick Example + +```csharp +using ObjectSemantics.NET; + +public class QuickCustomer +{ + public string Name { get; set; } +} + +public class QuickOrder +{ + public string Number { get; set; } + public QuickCustomer Customer { get; set; } + public decimal Total { get; set; } +} + +var order = new QuickOrder +{ + Number = "ORD-1001", + Customer = new QuickCustomer { Name = "Jane Doe" }, + Total = 2900m +}; + +string template = "Order {{ Number }} for {{ Customer.Name }} is {{ Total:N2 }}"; +string result = order.Map(template); +// Order ORD-1001 for Jane Doe is 2,900.00 +``` + +## What You Can Do + +- Map direct properties: `{{ Name }}` +- Map nested properties: `{{ Customer.BankingDetail.BankName }}` +- Loop collections: `{{ #foreach(Items) }}...{{ #endforeach }}` +- Evaluate conditions: `{{ #if(IsPaid == true) }}...{{ #endif }}` +- Format values: `{{ Amount:N2 }}`, `{{ Name:uppercase }}` +- Run calculations: `{{ __sum(Payments.Amount):N2 }}`, `{{ __calc(PaidAmount - CreditLimit):N2 }}` + +## Notes + +- Regular unknown placeholders stay unresolved so template authors can see them, for example: `{{ UnknownProp }}`. +- Calculation expressions (`sum/avg/count/min/max/calc`) return empty on invalid or unknown paths. +- Null sources/properties in calculation paths return zero. diff --git a/wiki/Recipes.md b/wiki/Recipes.md new file mode 100644 index 0000000..9cea574 --- /dev/null +++ b/wiki/Recipes.md @@ -0,0 +1,74 @@ +# Recipes + +## 1. Order Confirmation Email + +```text +Subject: Order {{ OrderNo }} Confirmed + +Hi {{ Customer.FullName:titlecase }}, + +Order Date: {{ OrderDate:yyyy-MM-dd }} +Payment Status: {{ #if(IsPaid == true) }}PAID{{ #else }}UNPAID{{ #endif }} + +Items: +{{ #foreach(Items) }} +- {{ Quantity }} x {{ Name }} = {{ LineTotal:N2 }} +{{ #endforeach }} + +Total: {{ Total:N2 }} +``` + +## 2. Marketing A/B Subject + +```text +{{ #if(CampaignVariant == A) }}Special offer for {{ Customer.FullName:titlecase }}{{ #else }}Do not miss out {{ Customer.FullName:titlecase }}{{ #endif }} +``` + +## 3. Channel Compliance Block (Email vs SMS) + +```text +Compliance: {{ #if(Meta.Channel == EMAIL) }}Unsubscribe via {{ Meta.UnsubscribeUrl }}{{ #else }}Reply STOP to unsubscribe{{ #endif }} +``` + +## 4. Ledger/Balance Snippet with Calculations + +```text +Paid: {{ PaidAmount:N2 }} +Credit Limit: {{ Customer.CreditLimit:N2 }} +Difference: {{ __calc(PaidAmount - Customer.CreditLimit):N2 }} +Payments Total: {{ __sum(Customer.Payments.Amount):N2 }} +``` + +## 5. XML-Safe Payload + +When producing XML templates: + +```csharp +string output = model.Map(template, extra, new TemplateMapperOptions +{ + XmlCharEscaping = true +}); +``` + +This safely escapes values like `&`, `<`, `>`, quotes, and apostrophes. + +## 6. Dot-Key Metadata for Messaging + +```csharp +var extra = new Dictionary +{ + ["Meta.Channel"] = "EMAIL", + ["Meta.UnsubscribeUrl"] = "https://example.com/unsubscribe/abc" +}; +``` + +```text +Channel: {{ Meta.Channel }} +Unsubscribe: {{ Meta.UnsubscribeUrl }} +``` + +## 7. Graceful Fallback for Personalization + +```text +{{ #if(Customer.FullName == null) }}Hello Customer{{ #else }}Hello {{ Customer.FullName:titlecase }}{{ #endif }} +``` diff --git a/wiki/Template-Syntax.md b/wiki/Template-Syntax.md new file mode 100644 index 0000000..30813b5 --- /dev/null +++ b/wiki/Template-Syntax.md @@ -0,0 +1,102 @@ +# Template Syntax + +## Placeholders + +Use double curly braces: + +```text +{{ PropertyName }} +``` + +Examples: + +- `{{ Name }}` +- `{{ Amount:N2 }}` +- `{{ Customer.BankingDetail.BankName }}` + +## Nested Paths + +Dot notation is supported: + +```text +{{ Customer.CompanyName }} +{{ Customer.BankingDetail.AccountNumber }} +``` + +If any intermediate nested object is null, output is empty for that placeholder. + +## Loops + +```text +{{ #foreach(Items) }} + {{ Name }} x {{ Quantity }} +{{ #endforeach }} +``` + +### Looping primitive arrays + +Use `.` for the current item: + +```text +{{ #foreach(Tags) }}[{{ . }}]{{ #endforeach }} +{{ #foreach(Tags) }}[{{ .:uppercase }}]{{ #endforeach }} +``` + +## Conditions + +```text +{{ #if(Age >= 18) }}Adult{{ #else }}Minor{{ #endif }} +``` + +Supported operators: + +- `==` +- `!=` +- `>` +- `>=` +- `<` +- `<=` + +Conditions work with numbers, strings, booleans, dates, and collection counts. + +## Formatting + +### Standard .NET style formats + +- Number: `N2`, `#,##0` +- DateTime: `yyyy-MM-dd`, `dd-MMM-yyyy hh:mm tt` + +Examples: + +- `{{ Price:N2 }}` +- `{{ Price:#,##0 }}` +- `{{ PaymentDate:yyyy-MM-dd }}` + +### Built-in string transforms + +| Format | Description | +| --- | --- | +| `uppercase` | Uppercase value | +| `lowercase` | Lowercase value | +| `titlecase` | Title case value | +| `length` | Character count | +| `ToMD5` | MD5 hash | +| `ToBase64` | Base64 encode | +| `FromBase64` | Base64 decode | + +Examples: + +- `{{ Name:uppercase }}` +- `{{ Value:ToBase64 }}` + +## Unknown Placeholder Behavior + +Regular placeholders with unknown property names remain unresolved: + +```text +{{ UnknownProperty }} +``` + +This helps content authors detect template mistakes quickly. + +Calculation expressions are handled differently; see [Calculations](Calculations.md). diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md new file mode 100644 index 0000000..50cc335 --- /dev/null +++ b/wiki/Troubleshooting.md @@ -0,0 +1,65 @@ +# Troubleshooting + +## Quick Diagnostics + +| Symptom | Likely Cause | What To Do | +| --- | --- | --- | +| `{{ PropertyName }}` remains in output | Unknown regular property | Check model/property spelling or add runtime parameter | +| Calculation renders empty | Invalid/unknown/non-numeric calculation path | Validate expression, path, and data type | +| Calculation renders `0` | Null source/property in math path | Confirm null is expected or initialize source values | +| `#if` block not matching | Type/operator mismatch | Verify property type and comparison value | +| Loop renders nothing | Source is null/non-enumerable/empty | Ensure collection exists and has items | + +## Unknown Placeholder vs Calculation Behavior + +These are intentionally different: + +- Regular placeholder unknown: keeps unresolved text + - Example: `{{ UnknownProp }}` +- Expression unknown/invalid: renders empty + - Example: `{{ __sum(Unknown.Path) }}` -> empty + +## Common Mistakes + +### 1. Wrong nested path + +```text +{{ Customer.BankDetails.BankName }} +``` + +If model uses `BankingDetail` (singular), this expression will fail. + +### 2. Using numeric functions on string/date + +```text +{{ __sum(Items.Name) }} +{{ __sum(OrderDate) }} +``` + +These render empty because values are not numeric. + +### 3. Using calc with unresolved fields + +```text +{{ __calc(PaidAmount - UnknownValue) }} +``` + +This renders empty. + +## Performance Tips + +- Reuse template strings to benefit from parser cache. +- Prefer specific property paths over deeply complex expressions. +- Keep heavy transformations outside templates when possible. +- Validate user-authored templates during save time, not only at send time. + +## Testing Strategy + +For production usage, include tests for: + +- Success paths (valid values) +- Null source/property paths +- Unknown paths +- Invalid expressions +- Non-numeric value misuse in numeric functions +- Mixed templates (loop + if + calculation + formatting) diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 0000000..5c9c3e2 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,8 @@ +## ObjectSemantics.NET + +- [Home](Home.md) +- [Getting Started](Getting-Started.md) +- [Template Syntax](Template-Syntax.md) +- [Calculations](Calculations.md) +- [Recipes](Recipes.md) +- [Troubleshooting](Troubleshooting.md) From 27a26956d84a0056f35b17998562610d2fc1c40d Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Sat, 7 Mar 2026 00:34:48 +0300 Subject: [PATCH 7/7] v7.1.0 --- ObjectSemantics.NET/ObjectSemantics.NET.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ObjectSemantics.NET/ObjectSemantics.NET.csproj b/ObjectSemantics.NET/ObjectSemantics.NET.csproj index 088593a..852467b 100644 --- a/ObjectSemantics.NET/ObjectSemantics.NET.csproj +++ b/ObjectSemantics.NET/ObjectSemantics.NET.csproj @@ -15,7 +15,7 @@ ToBase64 FromBase64 . Added template extension method to allow mapping directly from Template - 7.0.2 + 7.1.0 $(Version) $(Version) false