From 9b6efd91b2fca5f8d5ad9ce3df2e8c88ad6d4b80 Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Wed, 16 Sep 2026 22:03:12 +0300 Subject: [PATCH 1/2] chore: optimization patches and refactoring --- .../ObjectSemantics.NET.Benchmarks.csproj | 11 + ObjectSemantics.NET.Benchmarks/Program.cs | 12 + .../RenderingBenchmarks.cs | 80 ++++ .../CompiledTemplateTests.cs | 93 ++++ .../ObjectSemantics.NET.Tests.csproj | 24 +- .../OptimizedEvaluationTests.cs | 86 ++++ .../RenderingCompatibilityTests.cs | 98 +++++ .../StructuredRenderingTests.cs | 76 ++++ ObjectSemantics.NET.sln | 6 + ObjectSemantics.NET/CompiledTemplate.cs | 48 +++ .../Engine/EngineAlgorithim.cs | 18 - .../Engine/EngineExpressionEvaluator.cs | 403 +++++++++--------- .../Engine/EnginePropertyResolver.cs | 60 +-- .../Engine/EngineRenderContext.cs | 95 +++++ .../Engine/EngineTemplateCache.cs | 61 ++- .../Engine/EngineTemplateParser.cs | 208 +++++---- .../Engine/EngineTemplateRenderer.cs | 185 ++++---- .../Engine/EngineTypeMetadataCache.cs | 11 +- .../ExtractedObjPropertyExtensions.cs | 68 +-- .../Extensions/ReplaceCodeExtensions.cs | 36 -- .../Engine/Models/EngineRunnerTemplate.cs | 54 ++- .../Engine/Models/ReplaceCode.cs | 10 - .../Engine/Models/ReplaceIfOperationCode.cs | 12 - .../Engine/Models/ReplaceObjLoopCode.cs | 12 - .../ObjectSemantics.NET.csproj | 12 +- .../Properties/AssemblyInfo.cs | 4 + ObjectSemantics.NET/TemplateDiagnostic.cs | 10 + .../TemplateLimitExceededException.cs | 11 + ObjectSemantics.NET/TemplateMapper.cs | 34 +- ObjectSemantics.NET/TemplateMapperOptions.cs | 35 +- README.md | 401 ++++++++++++++--- 31 files changed, 1638 insertions(+), 636 deletions(-) create mode 100644 ObjectSemantics.NET.Benchmarks/ObjectSemantics.NET.Benchmarks.csproj create mode 100644 ObjectSemantics.NET.Benchmarks/Program.cs create mode 100644 ObjectSemantics.NET.Benchmarks/RenderingBenchmarks.cs create mode 100644 ObjectSemantics.NET.Tests/CompiledTemplateTests.cs create mode 100644 ObjectSemantics.NET.Tests/OptimizedEvaluationTests.cs create mode 100644 ObjectSemantics.NET.Tests/RenderingCompatibilityTests.cs create mode 100644 ObjectSemantics.NET.Tests/StructuredRenderingTests.cs create mode 100644 ObjectSemantics.NET/CompiledTemplate.cs delete mode 100644 ObjectSemantics.NET/Engine/EngineAlgorithim.cs create mode 100644 ObjectSemantics.NET/Engine/EngineRenderContext.cs delete mode 100644 ObjectSemantics.NET/Engine/Extensions/ReplaceCodeExtensions.cs delete mode 100644 ObjectSemantics.NET/Engine/Models/ReplaceCode.cs delete mode 100644 ObjectSemantics.NET/Engine/Models/ReplaceIfOperationCode.cs delete mode 100644 ObjectSemantics.NET/Engine/Models/ReplaceObjLoopCode.cs create mode 100644 ObjectSemantics.NET/Properties/AssemblyInfo.cs create mode 100644 ObjectSemantics.NET/TemplateDiagnostic.cs create mode 100644 ObjectSemantics.NET/TemplateLimitExceededException.cs diff --git a/ObjectSemantics.NET.Benchmarks/ObjectSemantics.NET.Benchmarks.csproj b/ObjectSemantics.NET.Benchmarks/ObjectSemantics.NET.Benchmarks.csproj new file mode 100644 index 0000000..ffdcf54 --- /dev/null +++ b/ObjectSemantics.NET.Benchmarks/ObjectSemantics.NET.Benchmarks.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + false + + + + + + diff --git a/ObjectSemantics.NET.Benchmarks/Program.cs b/ObjectSemantics.NET.Benchmarks/Program.cs new file mode 100644 index 0000000..0356fc1 --- /dev/null +++ b/ObjectSemantics.NET.Benchmarks/Program.cs @@ -0,0 +1,12 @@ +using BenchmarkDotNet.Running; + +namespace ObjectSemantics.NET.Benchmarks +{ + public class Program + { + public static void Main(string[] args) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } + } +} diff --git a/ObjectSemantics.NET.Benchmarks/RenderingBenchmarks.cs b/ObjectSemantics.NET.Benchmarks/RenderingBenchmarks.cs new file mode 100644 index 0000000..2d48c75 --- /dev/null +++ b/ObjectSemantics.NET.Benchmarks/RenderingBenchmarks.cs @@ -0,0 +1,80 @@ +using BenchmarkDotNet.Attributes; +using ObjectSemantics.NET.Engine; +using System.IO; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace ObjectSemantics.NET.Benchmarks +{ + [MemoryDiagnoser] + public class RenderingBenchmarks + { + private readonly BenchmarkModel _model = new BenchmarkModel(); + private string _manyValues; + private string[] _templates; + private int _templateIndex; + private CompiledTemplate _compiled; + private readonly TemplateMapperOptions _optimized = new TemplateMapperOptions { LazyPropertyAccess = true, UseStreamingEvaluation = true }; + private const string ExpressionTemplate = "{{ #foreach(Items) }}{{ __calc(Quantity * Price):N2 }};{{ #endforeach }}"; + + [GlobalSetup] + public void Setup() + { + for (int i = 0; i < 100; i++) + _model.Items.Add(new BenchmarkItem { Quantity = i + 1, Price = 12.5m }); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 200; i++) + text.Append("Hello {{ Name }}! "); + _manyValues = text.ToString(); + _templates = new string[4096]; + for (int i = 0; i < _templates.Length; i++) + _templates[i] = "Template " + i + " {{ Name }}"; + _model.Map(ExpressionTemplate); + _model.Map(_manyValues); + _compiled = TemplateMapper.Compile(_manyValues); + } + + [Benchmark] + public string WarmValues() { return _model.Map(_manyValues); } + + [Benchmark] + public string ExpressionLoop() { return _model.Map(ExpressionTemplate); } + + [Benchmark] + public string Aggregate() { return _model.Map("{{ __sum(Items.Price) }}|{{ __avg(Items.Price) }}"); } + + [Benchmark] + public string CacheChurn() { return _model.Map(_templates[_templateIndex++ & 4095]); } + + [Benchmark] + public object ColdParse() { return EngineTemplateParser.Parse(_manyValues); } + + [Benchmark] + public string CompiledValues() { return _compiled.Render(_model); } + + [Benchmark] + public void WriterValues() { _compiled.RenderTo(TextWriter.Null, _model); } + + [Benchmark] + public string StreamingAggregate() { return _model.Map("{{ __sum(Items.Price) }}|{{ __avg(Items.Price) }}", options: _optimized); } + + [Benchmark(OperationsPerInvoke = 32)] + public void ConcurrentRendering() + { + Parallel.For(0, 32, i => _model.Map(ExpressionTemplate)); + } + } + + public class BenchmarkModel + { + public string Name { get; set; } = "Customer"; + public List Items { get; set; } = new List(); + } + + public class BenchmarkItem + { + public int Quantity { get; set; } + public decimal Price { get; set; } + } +} diff --git a/ObjectSemantics.NET.Tests/CompiledTemplateTests.cs b/ObjectSemantics.NET.Tests/CompiledTemplateTests.cs new file mode 100644 index 0000000..2f4aa3f --- /dev/null +++ b/ObjectSemantics.NET.Tests/CompiledTemplateTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class CompiledTemplateTests + { + [Fact] + public void CompiledTemplateSupportsConcurrentIndependentRenders() + { + CompiledTemplate template = TemplateMapper.Compile("{{ Name }}={{ __calc(Value * 2) }}"); + Parallel.For(0, 250, i => + { + Model model = new Model(i.ToString(), i); + Assert.Equal(i + "=" + (i * 2), template.Render(model)); + }); + } + + [Fact] + public void WriterOutputMatchesStringAndWriterRemainsOpen() + { + CompiledTemplate template = TemplateMapper.Compile("{{ Name }}|{{ #foreach(Items) }}[{{ . }}]{{ #endforeach }}"); + Model model = new Model("", 3); + TemplateMapperOptions options = new TemplateMapperOptions { XmlCharEscaping = true }; + using (StringWriter writer = new StringWriter(CultureInfo.InvariantCulture)) + { + template.RenderTo(writer, model, options: options); + Assert.Equal(template.Render(model, options: options), writer.ToString()); + writer.Write("!"); + Assert.EndsWith("!", writer.ToString()); + } + } + + [Fact] + public void WriterLimitLeavesOnlyOutputWithinBudget() + { + CompiledTemplate template = TemplateMapper.Compile("A{{ Name }}"); + using (StringWriter writer = new StringWriter()) + { + Assert.Throws(() => template.RenderTo(writer, new Model("Long", 1), options: new TemplateMapperOptions { MaximumOutputCharacters = 2 })); + Assert.Equal("A", writer.ToString()); + } + } + + [Fact] + public void DiagnosticsHaveLocationsAndCannotMutateCachedTemplate() + { + CompiledTemplate template = TemplateMapper.Compile("First\n {{ #endif }}"); + IReadOnlyList diagnostics = template.Diagnostics; + Assert.Single(diagnostics); + Assert.Equal(2, diagnostics[0].Line); + Assert.Equal(3, diagnostics[0].Column); + diagnostics[0].Message = "changed"; + Assert.StartsWith("Unexpected", template.Diagnostics[0].Message); + Assert.Throws(() => TemplateMapper.Compile("{{ #endif }}", new TemplateMapperOptions { StrictMode = true })); + } + + [Fact] + public void LimitsApplyToCachedAndCompiledTemplates() + { + CompiledTemplate template = TemplateMapper.Compile("Hello {{ Name }}"); + TemplateMapperOptions options = new TemplateMapperOptions { MaximumTemplateCharacters = 3 }; + Assert.Throws(() => TemplateMapper.Compile("Hello {{ Name }}", options)); + Assert.Throws(() => template.Render(new Model("Test", 1), options: options)); + Assert.Throws(() => template.Render(new Model("Test", 1), options: new TemplateMapperOptions { MaximumIterations = -1 })); + } + + [Fact] + public void OverflowFollowsExpressionFailurePolicy() + { + CompiledTemplate template = TemplateMapper.Compile("A{{ __calc(79228162514264337593543950335 + 1) }}B"); + Assert.Equal("AB", template.Render(new Model("Test", 1))); + Assert.Throws(() => template.Render(new Model("Test", 1), options: new TemplateMapperOptions { StrictMode = true })); + } + + public class Model + { + public Model(string name, int value) + { + Name = name; + Value = value; + } + + public string Name { get; set; } + public int Value { get; set; } + public int[] Items { get; set; } = new[] { 1, 2, 3 }; + } + } +} diff --git a/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj b/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj index 5e4177c..9000525 100644 --- a/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj +++ b/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net10.0 false @@ -11,27 +11,17 @@ - - - - - - - Always - - - Always - - + + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/ObjectSemantics.NET.Tests/OptimizedEvaluationTests.cs b/ObjectSemantics.NET.Tests/OptimizedEvaluationTests.cs new file mode 100644 index 0000000..cc08056 --- /dev/null +++ b/ObjectSemantics.NET.Tests/OptimizedEvaluationTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class OptimizedEvaluationTests + { + [Fact] + public void LazyAccessSkipsUnusedGettersAndReadsOncePerScope() + { + GetterModel model = new GetterModel(); + Assert.Throws(() => model.Map("{{ Name }}")); + model.Reads = 0; + Assert.Equal("Alice|Alice|Alice", model.Map("{{ Name }}|{{ Name }}|{{ #if(Name == Alice) }}{{ Name }}{{ #endif }}", options: new TemplateMapperOptions { LazyPropertyAccess = true })); + Assert.Equal(1, model.Reads); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AggregatesPreserveNullEmptyAndInvalidBehavior(bool streaming) + { + Model model = new Model { Items = new List { new Item { Value = 2 }, new Item { Value = null }, new Item { Value = 4 } } }; + TemplateMapperOptions options = new TemplateMapperOptions { UseStreamingEvaluation = streaming }; + string template = "{{ __sum(Items.Value) }}|{{ __avg(Items.Value) }}|{{ __count(Items.Value) }}|{{ __min(Items.Value) }}|{{ __max(Items.Value) }}"; + Assert.Equal("6|3|2|2|4", model.Map(template, options: options)); + model.Items.Clear(); + Assert.Equal("||||", model.Map(template, options: options)); + model.Items = null; + Assert.Equal("0|0|0|0|0", model.Map(template, options: options)); + Assert.Equal("", model.Map("{{ __sum(Unknown.Value) }}", options: options)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AggregateEnumerationHonorsLimits(bool streaming) + { + Model model = new Model { Items = new List { new Item(), new Item() } }; + Assert.Throws(() => model.Map("{{ __sum(Items.Value) }}", options: new TemplateMapperOptions { UseStreamingEvaluation = streaming, MaximumIterations = 1 })); + } + + [Fact] + public void TypedFormattingPreservesDatePrecisionAndKind() + { + Model model = new Model { Date = new DateTime(2026, 9, 16, 12, 34, 56, DateTimeKind.Utc).AddTicks(1234567) }; + Assert.Equal(model.Date.ToString("O", CultureInfo.InvariantCulture), model.Map("{{ Date:O }}")); + } + + [Fact] + public void ConditionsHandleNullableValuesAndExactDecimals() + { + Model model = new Model { Flag = true, Number = 20, Amount = 9007199254740993m }; + Assert.Equal("Y", model.Map("{{ #if(Flag == true) }}Y{{ #else }}N{{ #endif }}")); + Assert.Equal("Y", model.Map("{{ #if(Number >= 18) }}Y{{ #else }}N{{ #endif }}")); + Assert.Equal("N", model.Map("{{ #if(Amount == 9007199254740992) }}Y{{ #else }}N{{ #endif }}")); + model.Flag = null; + Assert.Equal("Y", model.Map("{{ #if(Flag == null) }}Y{{ #else }}N{{ #endif }}")); + Assert.Equal("Y", model.Map("{{ #if(Numbers > 0) }}Y{{ #else }}N{{ #endif }}")); + } + + public class GetterModel + { + public int Reads { get; set; } + public string Name { get { Reads++; return "Alice"; } } + public string Unused { get { throw new InvalidOperationException("Unused getter"); } } + } + + public class Model + { + public List Items { get; set; } + public DateTime Date { get; set; } + public bool? Flag { get; set; } + public int? Number { get; set; } + public decimal Amount { get; set; } + public int[] Numbers { get; set; } = new[] { 1, 2 }; + } + + public class Item + { + public decimal? Value { get; set; } + } + } +} diff --git a/ObjectSemantics.NET.Tests/RenderingCompatibilityTests.cs b/ObjectSemantics.NET.Tests/RenderingCompatibilityTests.cs new file mode 100644 index 0000000..51c7cb7 --- /dev/null +++ b/ObjectSemantics.NET.Tests/RenderingCompatibilityTests.cs @@ -0,0 +1,98 @@ +using ObjectSemantics.NET.Engine; +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class RenderingCompatibilityTests + { + [Theory] + [InlineData("{{ missing }}", "{{ missing }}")] + [InlineData("{{ NAME }}", "Alice")] + [InlineData("{{ Items. .Count }}", "0")] + [InlineData("{{ __calc(10 - Missing) }}", "")] + [InlineData("{{ __calc(10 - Optional) }}", "0")] + [InlineData("{{ __calc(-2 * (3 + 4)) }}", "-14")] + [InlineData("{{ __calc(--2 - -3) }}", "5")] + [InlineData("{{ __calc(3 / -2) }}", "-1.5")] + [InlineData("{{ __calc(1 / 0) }}", "")] + [InlineData("{{ __sum(Items.Value) }}", "")] + [InlineData("{{ #foreach(Items) }}{{ Missing }}{{ #endforeach }}", "")] + public void PreservesExistingOutputRules(string template, string expected) + { + Assert.Equal(expected, new Model().Map(template)); + } + + [Fact] + public void PreservesMissingLoopPropertyAndDuplicateParameterRules() + { + Model model = new Model { Items = new List { new Item() } }; + Assert.Equal("Missing", model.Map("{{ #foreach(Items) }}{{ Missing }}{{ #endforeach }}")); + Assert.Throws(() => model.Map("{{ Name }}", new Dictionary { ["name"] = "Override" })); + } + + [Fact] + public void ConcurrentRendersDoNotShareValues() + { + Parallel.For(0, 500, i => + { + Model model = new Model { Name = i.ToString(), Optional = i }; + Assert.Equal(i + "|" + (i * 2), model.Map("{{ Name }}|{{ __calc(Optional * 2) }}")); + }); + } + + [Fact] + public void CacheInitializesOnceAndEvictsIncrementally() + { + EngineTemplateCache.CacheState cache = new EngineTemplateCache.CacheState(2, 100); + int calls = 0; + Func factory = text => + { + Interlocked.Increment(ref calls); + return new EngineRunnerTemplate { Template = text }; + }; + Parallel.For(0, 100, i => cache.GetOrAdd("first", factory)); + Assert.Equal(1, calls); + EngineRunnerTemplate second = cache.GetOrAdd("second", factory); + cache.GetOrAdd("third", factory); + Assert.Same(second, cache.GetOrAdd("second", factory)); + cache.GetOrAdd("first", factory); + Assert.Equal(4, calls); + } + + [Fact] + public void OversizedTemplatesBypassCache() + { + EngineTemplateCache.CacheState cache = new EngineTemplateCache.CacheState(2, 3); + EngineRunnerTemplate first = cache.GetOrAdd("large", EngineTemplateParser.Parse); + Assert.NotSame(first, cache.GetOrAdd("large", EngineTemplateParser.Parse)); + } + + [Fact] + public void SourceBudgetEvictsOnlyAsMuchAsNeeded() + { + EngineTemplateCache.CacheState cache = new EngineTemplateCache.CacheState(10, 6); + EngineRunnerTemplate first = cache.GetOrAdd("aaa", EngineTemplateParser.Parse); + EngineRunnerTemplate second = cache.GetOrAdd("bb", EngineTemplateParser.Parse); + cache.GetOrAdd("cc", EngineTemplateParser.Parse); + Assert.Same(second, cache.GetOrAdd("bb", EngineTemplateParser.Parse)); + Assert.NotSame(first, cache.GetOrAdd("aaa", EngineTemplateParser.Parse)); + } + + public class Model + { + public string Name { get; set; } = "Alice"; + public int? Optional { get; set; } + public List Items { get; set; } = new List(); + } + + public class Item + { + public decimal Value { get; set; } + } + } +} diff --git a/ObjectSemantics.NET.Tests/StructuredRenderingTests.cs b/ObjectSemantics.NET.Tests/StructuredRenderingTests.cs new file mode 100644 index 0000000..6b4f6d5 --- /dev/null +++ b/ObjectSemantics.NET.Tests/StructuredRenderingTests.cs @@ -0,0 +1,76 @@ +using ObjectSemantics.NET.Engine; +using System; +using System.Collections.Generic; +using System.Threading; +using Xunit; + +namespace ObjectSemantics.NET.Tests +{ + public class StructuredRenderingTests + { + [Fact] + public void KeepsLiteralAndModelMarkersUnchanged() + { + Model model = new Model { Name = "RP_2" }; + Assert.Equal("RP_1|RP_2|Bob", model.Map("RP_1|{{ Name }}|{{ Other }}")); + Assert.Equal("{{ Other }}|Bob", new Model { Name = "{{ Other }}" }.Map("{{ Name }}|{{ Other }}")); + } + + [Fact] + public void SupportsNestedConditionsAndRowConditions() + { + Model model = new Model(); + Assert.Equal("ABC", model.Map("{{ #if(Active == true) }}A{{ #if(Active == true) }}B{{ #endif }}C{{ #endif }}")); + Assert.Equal("YN", model.Map("{{ #foreach(Items) }}{{ #if(Active == true) }}Y{{ #else }}N{{ #endif }}{{ #endforeach }}")); + } + + [Fact] + public void SupportsNestedLoopsAndScalarRows() + { + Model model = new Model(); + Assert.Equal("[1][2][3]", model.Map("{{ #foreach(Items) }}{{ #foreach(Numbers) }}[{{ . }}]{{ #endforeach }}{{ #endforeach }}")); + } + + [Theory] + [InlineData("{{ #if(Active == true) }}", "Unclosed block")] + [InlineData("\n{{ #endif }}", "Unexpected block")] + [InlineData("{{ Name", "Unclosed template")] + [InlineData("{{ __calc(1 + ()) }}", "Invalid arithmetic")] + [InlineData("{{ __calc(1 2 +) }}", "Invalid arithmetic")] + [InlineData("{{ __calc(2(3)) }}", "Invalid arithmetic")] + public void ReportsMalformedTemplates(string template, string message) + { + Assert.Contains(EngineTemplateParser.Parse(template).Diagnostics, d => d.Message.StartsWith(message, StringComparison.Ordinal)); + Assert.Throws(() => new Model().Map(template, options: new TemplateMapperOptions { StrictMode = true })); + } + + [Fact] + public void EnforcesOutputIterationAndNestingLimits() + { + Model model = new Model(); + Assert.Throws(() => model.Map("1234", options: new TemplateMapperOptions { MaximumOutputCharacters = 3 })); + Assert.Throws(() => model.Map("{{ #foreach(Items) }}x{{ #endforeach }}", options: new TemplateMapperOptions { MaximumIterations = 1 })); + Assert.Throws(() => model.Map("{{ #foreach(Items) }}{{ #foreach(Numbers) }}x{{ #endforeach }}{{ #endforeach }}", options: new TemplateMapperOptions { MaximumNestingDepth = 1 })); + Assert.Throws(() => model.Map("plain", options: new TemplateMapperOptions { CancellationToken = new CancellationToken(true) })); + Assert.Throws(() => model.Map("{{ Unknown }}", options: new TemplateMapperOptions { StrictMode = true })); + } + + public class Model + { + public string Name { get; set; } = "Alice"; + public string Other { get; set; } = "Bob"; + public bool Active { get; set; } = true; + public List Items { get; set; } = new List + { + new Item { Active = true, Numbers = new[] { 1, 2 } }, + new Item { Active = false, Numbers = new[] { 3 } } + }; + } + + public class Item + { + public bool Active { get; set; } + public int[] Numbers { get; set; } + } + } +} diff --git a/ObjectSemantics.NET.sln b/ObjectSemantics.NET.sln index 08f8bf9..2fc5c9f 100644 --- a/ObjectSemantics.NET.sln +++ b/ObjectSemantics.NET.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ObjectSemantics.NET", "Obje EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObjectSemantics.NET.Tests", "ObjectSemantics.NET.Tests\ObjectSemantics.NET.Tests.csproj", "{2BAC0553-674B-413F-ABC8-9DF4901E2D62}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObjectSemantics.NET.Benchmarks", "ObjectSemantics.NET.Benchmarks\ObjectSemantics.NET.Benchmarks.csproj", "{FB61CDBB-C615-44F3-A6D4-D90C37FFD563}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {2BAC0553-674B-413F-ABC8-9DF4901E2D62}.Debug|Any CPU.Build.0 = Debug|Any CPU {2BAC0553-674B-413F-ABC8-9DF4901E2D62}.Release|Any CPU.ActiveCfg = Release|Any CPU {2BAC0553-674B-413F-ABC8-9DF4901E2D62}.Release|Any CPU.Build.0 = Release|Any CPU + {FB61CDBB-C615-44F3-A6D4-D90C37FFD563}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB61CDBB-C615-44F3-A6D4-D90C37FFD563}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB61CDBB-C615-44F3-A6D4-D90C37FFD563}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB61CDBB-C615-44F3-A6D4-D90C37FFD563}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ObjectSemantics.NET/CompiledTemplate.cs b/ObjectSemantics.NET/CompiledTemplate.cs new file mode 100644 index 0000000..ad9eecf --- /dev/null +++ b/ObjectSemantics.NET/CompiledTemplate.cs @@ -0,0 +1,48 @@ +using ObjectSemantics.NET.Engine; +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections.Generic; +using System.IO; + +namespace ObjectSemantics.NET +{ + /// A reusable parsed template. Each render owns its model values and execution state. + public class CompiledTemplate + { + private readonly EngineRunnerTemplate _template; + + internal CompiledTemplate(EngineRunnerTemplate template) + { + _template = template; + } + + /// Returns a diagnostic snapshot; changing it does not modify the cached template. + public IReadOnlyList Diagnostics + { + get + { + TemplateDiagnostic[] diagnostics = new TemplateDiagnostic[_template.Diagnostics.Length]; + for (int i = 0; i < diagnostics.Length; i++) + { + TemplateDiagnostic source = _template.Diagnostics[i]; + diagnostics[i] = new TemplateDiagnostic { Message = source.Message, Position = source.Position, Line = source.Line, Column = source.Column }; + } + return Array.AsReadOnly(diagnostics); + } + } + + public string Render(T model, Dictionary parameters = null, TemplateMapperOptions options = null) where T : class + { + if (model == null) return string.Empty; + return EngineTemplateRenderer.Render(model, _template, parameters, options); + } + + /// Writes without buffering the entire output. Failures can leave partial output; the writer remains open. + public void RenderTo(TextWriter writer, T model, Dictionary parameters = null, TemplateMapperOptions options = null) where T : class + { + if (writer == null) throw new ArgumentNullException(nameof(writer)); + if (model == null) return; + EngineTemplateRenderer.RenderTo(writer, model, typeof(T), _template, parameters, options); + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineAlgorithim.cs b/ObjectSemantics.NET/Engine/EngineAlgorithim.cs deleted file mode 100644 index 694e938..0000000 --- a/ObjectSemantics.NET/Engine/EngineAlgorithim.cs +++ /dev/null @@ -1,18 +0,0 @@ -using ObjectSemantics.NET.Engine.Models; -using System.Collections.Generic; - -namespace ObjectSemantics.NET.Engine -{ - internal static class EngineAlgorithim - { - public static string GenerateFromTemplate(T record, EngineRunnerTemplate template, Dictionary parameterKeyValues = null, TemplateMapperOptions options = null) where T : new() - { - return EngineTemplateRenderer.Render(record, template, parameterKeyValues, options); - } - - internal static EngineRunnerTemplate GenerateRunnerTemplate(string fileContent) - { - return EngineTemplateCache.GetOrAdd(fileContent, EngineTemplateParser.Parse); - } - } -} diff --git a/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs b/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs index b67c15d..7878b82 100644 --- a/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs +++ b/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs @@ -9,87 +9,127 @@ 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); + private static readonly Regex FunctionRegex = new Regex(@"^\s*_*(?sum|avg|count|min|max|calc)\s*\(\s*(?.*)\s*\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - public static bool TryEvaluate(string expressionCommand, object rootRecord, Dictionary propMap, out ExtractedObjProperty evaluatedProperty, out bool renderEmptyOnFailure, out bool isExpressionCommand) + internal class ExpressionPlan { - evaluatedProperty = null; - renderEmptyOnFailure = false; - isExpressionCommand = false; - if (string.IsNullOrWhiteSpace(expressionCommand)) - return false; + public string Command { get; set; } + public string Function { get; set; } + public PropertyPath Argument { get; set; } + public ExpressionToken[] Instructions { get; set; } + } - Match match = FunctionRegex.Match(expressionCommand.Trim()); + public static ExpressionPlan Prepare(string command) + { + if (string.IsNullOrWhiteSpace(command)) + return null; + Match match = FunctionRegex.Match(command.Trim()); if (!match.Success) - return false; - isExpressionCommand = true; + return null; + ExpressionPlan plan = new ExpressionPlan + { + Command = command, + Function = match.Groups["fn"].Value.Trim().ToLowerInvariant(), + Argument = new PropertyPath(match.Groups["arg"].Value.Trim()) + }; + if (plan.Function == "calc" && TryTokenize(plan.Argument.Text, out List tokens) && TryToRpn(tokens, out List instructions)) + { + int stackDepth = 0; + bool valid = true; + for (int i = 0; i < instructions.Count; i++) + { + ExpressionToken token = instructions[i]; + if (token.Kind == ExpressionTokenKind.Number || token.Kind == ExpressionTokenKind.Identifier) stackDepth++; + else if (token.Kind == ExpressionTokenKind.UnaryMinus) valid &= stackDepth >= 1; + else { valid &= stackDepth >= 2; stackDepth--; } + } + if (valid && stackDepth == 1) plan.Instructions = instructions.ToArray(); + } + return plan; + } - string fn = match.Groups["fn"].Value.Trim().ToLowerInvariant(); - string arg = match.Groups["arg"].Value.Trim(); + public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out ExtractedObjProperty evaluatedProperty, out bool renderEmptyOnFailure, out bool isExpressionCommand) + { + evaluatedProperty = null; + renderEmptyOnFailure = false; + isExpressionCommand = plan != null; + if (plan == null) + return false; + string expressionCommand = plan.Command; + string fn = plan.Function; + PropertyPath arg = plan.Argument; - switch (fn) + try { - case "sum": - if (!TryAggregateNumeric(arg, rootRecord, propMap, AggregateMode.Sum, out decimal sum)) - { - renderEmptyOnFailure = true; - return false; - } - evaluatedProperty = CreateDecimalProperty(expressionCommand, sum); - return true; + switch (fn) + { + case "sum": + if (!TryAggregateNumeric(arg, 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 "avg": + if (!TryAggregateNumeric(arg, 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 "count": + if (!TryCount(arg, 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, propMap, AggregateMode.Min, out decimal min)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, min); + 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, propMap, AggregateMode.Max, out decimal max)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, max); + 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(plan.Instructions, propMap, out decimal calcResult)) + { + renderEmptyOnFailure = true; + return false; + } + evaluatedProperty = CreateDecimalProperty(expressionCommand, calcResult); + return true; + } - case "calc": - if (!TryEvaluateArithmetic(arg, rootRecord, propMap, out decimal calcResult)) - { - renderEmptyOnFailure = true; - return false; - } - evaluatedProperty = CreateDecimalProperty(expressionCommand, calcResult); - return true; + return false; + } + catch (OverflowException) + { + renderEmptyOnFailure = true; + return false; } - - return false; } private static ExtractedObjProperty CreateDecimalProperty(string name, decimal value) @@ -102,50 +142,45 @@ private static ExtractedObjProperty CreateDecimalProperty(string name, decimal v }; } - private static bool TryCount(string path, object rootRecord, Dictionary propMap, out int count) + private static bool TryCount(PropertyPath path, RenderScope propMap, out int count) { count = 0; - if (string.IsNullOrWhiteSpace(path)) - return false; - - List values = ResolvePathValues(path, rootRecord, propMap); - if (values.Count == 0) + int nonNullCount = 0; + if (string.IsNullOrWhiteSpace(path.Text)) return false; - for (int i = 0; i < values.Count; i++) + bool found = false; + VisitPathValues(path, propMap, value => { - if (values[i] != null) - count++; - } - return true; + found = true; + if (value != null) nonNullCount++; + }); + count = nonNullCount; + return found; } - private static bool TryAggregateNumeric(string path, object rootRecord, Dictionary propMap, AggregateMode mode, out decimal result) + private static bool TryAggregateNumeric(PropertyPath path, RenderScope propMap, AggregateMode mode, out decimal result) { result = 0m; - if (string.IsNullOrWhiteSpace(path)) - return false; - - List values = ResolvePathValues(path, rootRecord, propMap); - - if (values.Count == 0) + if (string.IsNullOrWhiteSpace(path.Text)) return false; + bool found = false; bool hasAny = false; bool hasInvalidNonNumeric = false; decimal running = 0m; int numericCount = 0; - for (int i = 0; i < values.Count; i++) + VisitPathValues(path, propMap, rawValue => { - object rawValue = values[i]; + found = true; if (rawValue == null) - continue; + return; if (!TryConvertToDecimal(rawValue, out decimal numeric)) { hasInvalidNonNumeric = true; - continue; + return; } if (!hasAny) @@ -171,9 +206,9 @@ private static bool TryAggregateNumeric(string path, object rootRecord, Dictiona } numericCount++; - } + }); - if (hasInvalidNonNumeric) + if (!found || hasInvalidNonNumeric) return false; if (!hasAny) @@ -190,97 +225,65 @@ private static bool TryAggregateNumeric(string path, object rootRecord, Dictiona return true; } - private static List ResolvePathValues(string path, object rootRecord, Dictionary propMap) + private static void VisitPathValues(PropertyPath path, RenderScope scope, Action visit) { - 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++) + if (scope.TryGetValue(path.Text, out ExtractedObjProperty direct)) { visit(direct.OriginalValue); return; } + if (!scope.TryGetValue(path.Root, out ExtractedObjProperty root)) return; + if (path.Segments.Length == 0) { visit(root.OriginalValue); return; } + if (scope.Options.UseStreamingEvaluation) { - 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)) + WalkPath(root.OriginalValue, path.Segments, 0, scope.Context, visit, 0); return; + } - if (current == null) + // Compatibility mode preserves breadth-first getter evaluation. + List current = new List { root.OriginalValue }; + for (int i = 0; i < path.Segments.Length; i++) { - nextValues.Add(null); - return; + List next = new List(); + for (int j = 0; j < current.Count; j++) + ExpandSegment(current[j], path.Segments[i], next, scope.Context, 0); + current = next; } + for (int i = 0; i < current.Count; i++) visit(current[i]); + } - if (current is IEnumerable enumerable && !(current is string)) + private static void WalkPath( + object value, + string[] segments, + int index, + EngineRenderContext context, + Action visit, + int depth) + { + context.Check(depth); + if (index == segments.Length || value == null) { visit(value); return; } + if (value is IEnumerable rows && !(value is string)) { - foreach (object item in enumerable) - ExpandSegmentValues(item, segment, nextValues); - return; + foreach (object row in rows) + { + context.CountIteration(); + WalkPath(row, segments, index, context, visit, depth + 1); + } } - - Type type = current.GetType(); - if (!EngineTypeMetadataCache.TryGetPropertyAccessor(type, segment, out PropertyAccessor accessor)) - return; - - nextValues.Add(accessor.Getter(current)); + else if (EngineTypeMetadataCache.TryGetPropertyAccessor(value.GetType(), segments[index], out PropertyAccessor accessor)) + WalkPath(accessor.Getter(value), segments, index + 1, context, visit, depth + 1); } - private static string[] SplitPathSegments(string path) + private static void ExpandSegment(object value, string segment, List next, EngineRenderContext context, int depth) { - 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++) + context.Check(depth); + if (value == null) { next.Add(null); return; } + if (value is IEnumerable rows && !(value is string)) { - string trimmed = raw[i].Trim(); - if (trimmed.Length == 0) - continue; - raw[count] = trimmed; - count++; + foreach (object row in rows) + { + context.CountIteration(); + ExpandSegment(row, segment, next, context, depth + 1); + } } - - if (count == raw.Length) - return raw; - - string[] trimmedSegments = new string[count]; - Array.Copy(raw, trimmedSegments, count); - return trimmedSegments; + else if (EngineTypeMetadataCache.TryGetPropertyAccessor(value.GetType(), segment, out PropertyAccessor accessor)) + next.Add(accessor.Getter(value)); } private static bool TryConvertToDecimal(object value, out decimal number) @@ -332,20 +335,15 @@ private static bool TryConvertToDecimal(object value, out decimal number) return false; } - private static bool TryEvaluateArithmetic(string expression, object rootRecord, Dictionary propMap, out decimal result) + private static bool TryEvaluateArithmetic(ExpressionToken[] rpn, RenderScope 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)) + if (rpn == null) return false; Stack stack = new Stack(); bool hasNullOperand = false; - for (int i = 0; i < rpn.Count; i++) + for (int i = 0; i < rpn.Length; i++) { ExpressionToken token = rpn[i]; switch (token.Kind) @@ -354,7 +352,7 @@ private static bool TryEvaluateArithmetic(string expression, object rootRecord, stack.Push(token.NumberValue); break; case ExpressionTokenKind.Identifier: - IdentifierResolveMode identifierResolveMode = TryResolveIdentifierToNumber(token.TextValue, rootRecord, propMap, out decimal identifierValue); + IdentifierResolveMode identifierResolveMode = TryResolveIdentifierToNumber(token.Path, propMap, out decimal identifierValue); if (identifierResolveMode == IdentifierResolveMode.UnknownPath || identifierResolveMode == IdentifierResolveMode.NonNumeric || identifierResolveMode == IdentifierResolveMode.Ambiguous) return false; @@ -406,20 +404,22 @@ private static bool TryEvaluateArithmetic(string expression, object rootRecord, return true; } - private static IdentifierResolveMode TryResolveIdentifierToNumber(string identifier, object rootRecord, Dictionary propMap, out decimal number) + private static IdentifierResolveMode TryResolveIdentifierToNumber(PropertyPath identifier, RenderScope propMap, out decimal number) { number = 0m; - if (string.IsNullOrWhiteSpace(identifier)) - return IdentifierResolveMode.UnknownPath; - - List values = ResolvePathValues(identifier, rootRecord, propMap); - if (values.Count == 0) + if (string.IsNullOrWhiteSpace(identifier.Text)) return IdentifierResolveMode.UnknownPath; - if (values.Count > 1) - return IdentifierResolveMode.Ambiguous; + object singleValue = null; + int count = 0; + VisitPathValues(identifier, propMap, value => + { + singleValue = value; + count++; + }); + if (count == 0) return IdentifierResolveMode.UnknownPath; + if (count > 1) return IdentifierResolveMode.Ambiguous; - object singleValue = values[0]; if (singleValue == null) return IdentifierResolveMode.NullValue; @@ -528,10 +528,29 @@ private static bool TryToRpn(List tokens, out List operators = new Stack(); ExpressionToken previousToken = default; bool hasPrevious = false; + bool expectsOperand = true; for (int i = 0; i < tokens.Count; i++) { ExpressionToken token = tokens[i]; + if (token.Kind == ExpressionTokenKind.Number || token.Kind == ExpressionTokenKind.Identifier) + { + if (!expectsOperand) return false; + expectsOperand = false; + } + else if (token.Kind == ExpressionTokenKind.LeftParenthesis) + { + if (!expectsOperand) return false; + } + else if (token.Kind == ExpressionTokenKind.RightParenthesis) + { + if (expectsOperand) return false; + } + else if (token.Kind == ExpressionTokenKind.Operator) + { + if (expectsOperand && token.OperatorChar != '-') return false; + expectsOperand = true; + } switch (token.Kind) { case ExpressionTokenKind.Number: @@ -599,7 +618,7 @@ private static bool TryToRpn(List tokens, out List 0; + return !expectsOperand && rpn.Count > 0; } private static bool IsOperatorToken(ExpressionToken token) @@ -634,7 +653,7 @@ private enum AggregateMode Max } - private enum ExpressionTokenKind + internal enum ExpressionTokenKind { Number, Identifier, @@ -644,11 +663,11 @@ private enum ExpressionTokenKind UnaryMinus } - private struct ExpressionToken + internal struct ExpressionToken { public ExpressionTokenKind Kind; public decimal NumberValue; - public string TextValue; + public PropertyPath Path; public char OperatorChar; public static ExpressionToken Number(decimal value) @@ -658,7 +677,7 @@ public static ExpressionToken Number(decimal value) public static ExpressionToken Identifier(string value) { - return new ExpressionToken { Kind = ExpressionTokenKind.Identifier, TextValue = value }; + return new ExpressionToken { Kind = ExpressionTokenKind.Identifier, Path = new PropertyPath(value) }; } public static ExpressionToken Operator(char op) diff --git a/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs b/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs index 729b97c..7eb0b5c 100644 --- a/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs +++ b/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs @@ -1,46 +1,36 @@ 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) + public static bool TryResolveProperty(RenderScope propMap, PropertyPath propertyPath, out ExtractedObjProperty result) { result = null; - if (propMap == null || string.IsNullOrWhiteSpace(propertyPath)) + if (propMap == null || propertyPath == null || string.IsNullOrWhiteSpace(propertyPath.Text)) return false; - string path = propertyPath.Trim(); + string path = propertyPath.Text; 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)) + string rootName = propertyPath.Root; + if (propertyPath.Segments.Length == 0) return false; if (!propMap.TryGetValue(rootName, out ExtractedObjProperty rootProperty)) return false; - return TryResolveNestedProperty(rootProperty, nestedPath, path, out result); + return TryResolveNestedProperty(rootProperty, propertyPath.Segments, path, out result); } - private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string nestedPath, string fullPath, out ExtractedObjProperty result) + private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string[] segments, string fullPath, out ExtractedObjProperty result) { result = null; - if (rootProperty == null || string.IsNullOrWhiteSpace(nestedPath)) + if (rootProperty == null) return false; - string[] segments = GetPathSegments(nestedPath); if (segments.Length == 0) return false; @@ -52,7 +42,7 @@ private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, if (currentType == null) return false; - string segment = segments[i].Trim(); + string segment = segments[i]; if (string.IsNullOrEmpty(segment)) return false; @@ -79,37 +69,5 @@ private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, 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/EngineRenderContext.cs b/ObjectSemantics.NET/Engine/EngineRenderContext.cs new file mode 100644 index 0000000..fe06a61 --- /dev/null +++ b/ObjectSemantics.NET/Engine/EngineRenderContext.cs @@ -0,0 +1,95 @@ +using ObjectSemantics.NET.Engine.Models; +using System; +using System.Collections.Generic; +using System.IO; + +namespace ObjectSemantics.NET.Engine +{ + internal class EngineRenderContext + { + private readonly TextWriter _writer; + private long _outputLength; + private long _iterations; + + public EngineRenderContext(TextWriter writer, TemplateMapperOptions options) + { + _writer = writer; + Options = options; + } + + public TemplateMapperOptions Options { get; set; } + + public void Check(int depth) + { + Options.CancellationToken.ThrowIfCancellationRequested(); + if (Options.MaximumNestingDepth > 0 && depth > Options.MaximumNestingDepth) + throw new TemplateLimitExceededException("Template nesting limit exceeded."); + } + + public void CountIteration() + { + Options.CancellationToken.ThrowIfCancellationRequested(); + if (Options.MaximumIterations > 0 && ++_iterations > Options.MaximumIterations) + throw new TemplateLimitExceededException("Template iteration limit exceeded."); + } + + public void Write(string value) + { + Options.CancellationToken.ThrowIfCancellationRequested(); + long length = value == null ? 0 : value.Length; + if (Options.MaximumOutputCharacters > 0 && length > Options.MaximumOutputCharacters - _outputLength) + throw new TemplateLimitExceededException("Template output limit exceeded."); + _outputLength += length; + _writer.Write(value); + } + } + + internal class RenderScope + { + private Dictionary _properties; + + public RenderScope(object model, Type type, Dictionary parameters, bool isRow, TemplateMapperOptions options) + { + Model = model; + Type = type; + Parameters = parameters; + IsRow = isRow; + Options = options; + if (options.LazyPropertyAccess) + { + _properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (parameters != null) + { + foreach (KeyValuePair parameter in parameters) + { + if (EngineTypeMetadataCache.TryGetPropertyAccessor(type, parameter.Key, out _)) + throw new ArgumentException("Additional parameter duplicates a model property: " + parameter.Key); + _properties.Add(parameter.Key, new ExtractedObjProperty { Name = parameter.Key, Type = parameter.Value == null ? typeof(object) : parameter.Value.GetType(), OriginalValue = parameter.Value }); + } + } + } + else if (!isRow) + _properties = EngineTypeMetadataCache.BuildPropertyMap(model, type, parameters); + } + + public object Model { get; set; } + public Type Type { get; set; } + public Dictionary Parameters { get; set; } + public bool IsRow { get; set; } + public TemplateMapperOptions Options { get; set; } + public EngineRenderContext Context { get; set; } + + public bool TryGetValue(string name, out ExtractedObjProperty property) + { + if (_properties == null) + _properties = EngineTypeMetadataCache.BuildPropertyMap(Model, Type, Parameters); + if (_properties.TryGetValue(name, out property)) + return true; + if (!Options.LazyPropertyAccess || !EngineTypeMetadataCache.TryGetPropertyAccessor(Type, name, out PropertyAccessor accessor)) + return false; + property = new ExtractedObjProperty { Name = name, Type = accessor.PropertyType, OriginalValue = Model == null ? null : accessor.Getter(Model) }; + _properties.Add(name, property); + return true; + } + } +} diff --git a/ObjectSemantics.NET/Engine/EngineTemplateCache.cs b/ObjectSemantics.NET/Engine/EngineTemplateCache.cs index ffee25b..b829b63 100644 --- a/ObjectSemantics.NET/Engine/EngineTemplateCache.cs +++ b/ObjectSemantics.NET/Engine/EngineTemplateCache.cs @@ -1,28 +1,69 @@ using ObjectSemantics.NET.Engine.Models; using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; namespace ObjectSemantics.NET.Engine { internal static class EngineTemplateCache { - private const int MaxEntries = 2048; + private static CacheState _state = new CacheState(2048, 16 * 1024 * 1024); - private static readonly ConcurrentDictionary Cache = new ConcurrentDictionary(StringComparer.Ordinal); + public static void Configure(int capacity, long maximumSourceCharacters) + { + if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity)); + if (maximumSourceCharacters < 1) throw new ArgumentOutOfRangeException(nameof(maximumSourceCharacters)); + Volatile.Write(ref _state, new CacheState(capacity, maximumSourceCharacters)); + } public static EngineRunnerTemplate GetOrAdd(string templateContent, Func factory) { - string key = templateContent ?? string.Empty; - if (Cache.TryGetValue(key, out EngineRunnerTemplate cachedTemplate)) - return cachedTemplate; + return Volatile.Read(ref _state).GetOrAdd(templateContent ?? string.Empty, factory); + } - EngineRunnerTemplate createdTemplate = factory == null ? new EngineRunnerTemplate { Template = key } : factory(key); + // FIFO eviction keeps cache hits lock-free. The character budget bounds retained source, + // not total managed memory; parsed nodes and compiled accessors have additional overhead. + internal class CacheState + { + private readonly int _capacity; + private readonly long _maximumSourceCharacters; + private readonly object _gate = new object(); + private readonly ConcurrentDictionary> _entries = new ConcurrentDictionary>(StringComparer.Ordinal); + private readonly Queue _order = new Queue(); + private long _sourceCharacters; - if (Cache.Count >= MaxEntries) - Cache.Clear(); + public CacheState(int capacity, long maximumSourceCharacters) + { + _capacity = capacity; + _maximumSourceCharacters = maximumSourceCharacters; + } - Cache.TryAdd(key, createdTemplate); - return createdTemplate; + public EngineRunnerTemplate GetOrAdd(string key, Func factory) + { + if (key.Length > _maximumSourceCharacters) + return factory(key); + if (!_entries.TryGetValue(key, out Lazy entry)) + { + lock (_gate) + { + if (!_entries.TryGetValue(key, out entry)) + { + while (_order.Count >= _capacity || _sourceCharacters + key.Length > _maximumSourceCharacters) + { + string oldest = _order.Dequeue(); + _entries.TryRemove(oldest, out _); + _sourceCharacters -= oldest.Length; + } + entry = new Lazy(() => factory(key), LazyThreadSafetyMode.ExecutionAndPublication); + _entries.TryAdd(key, entry); + _order.Enqueue(key); + _sourceCharacters += key.Length; + } + } + } + return entry.Value; + } } } } diff --git a/ObjectSemantics.NET/Engine/EngineTemplateParser.cs b/ObjectSemantics.NET/Engine/EngineTemplateParser.cs index 3a129c0..15ff9ba 100644 --- a/ObjectSemantics.NET/Engine/EngineTemplateParser.cs +++ b/ObjectSemantics.NET/Engine/EngineTemplateParser.cs @@ -1,103 +1,157 @@ using ObjectSemantics.NET.Engine.Models; -using System.Text.RegularExpressions; +using System; +using System.Collections.Generic; 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 => + string source = templateContent ?? string.Empty; + List roots = new List(); + List active = roots; + List diagnostics = new List(); + Stack blocks = new Stack(); + int offset = 0; + int maximumDepth = 0; + List lineStarts = null; + while (offset < source.Length) { - key++; - string refKey = "RIB_" + key; - templatedContent.ReplaceIfConditionCodes.Add(new ReplaceIfOperationCode + int start = source.IndexOf("{{", offset, StringComparison.Ordinal); + if (start < 0) { - 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 + active.Add(new TemplateNode { Kind = TemplateNodeKind.Literal, Text = source.Substring(offset), Position = offset }); + break; + } + if (start > offset) + active.Add(new TemplateNode { Kind = TemplateNodeKind.Literal, Text = source.Substring(offset, start - offset), Position = offset }); + int end = source.IndexOf("}}", start + 2, StringComparison.Ordinal); + if (end < 0) { - ReplaceRef = refKey, - TargetObjectName = m.Groups["target"].Value?.Trim() ?? string.Empty - }; - - string loopBlock = m.Groups["body"].Value; - loopBlock = DirectParamRegex.Replace(loopBlock, pm => + AddDiagnostic(diagnostics, source, ref lineStarts, start, "Unclosed template expression."); + active.Add(new TemplateNode { Kind = TemplateNodeKind.Literal, Text = source.Substring(start), Position = start }); + break; + } + string command = source.Substring(start + 2, end - start - 2).Trim(); + offset = end + 2; + string directive = command.StartsWith("#", StringComparison.Ordinal) ? command.Substring(1).Trim() : null; + if (directive != null && (directive.Equals("else", StringComparison.OrdinalIgnoreCase) || directive.Equals("endif", StringComparison.OrdinalIgnoreCase) || directive.Equals("endforeach", StringComparison.OrdinalIgnoreCase))) { - 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 => + bool isElse = directive.Equals("else", StringComparison.OrdinalIgnoreCase); + TemplateNodeKind expected = directive.Equals("endforeach", StringComparison.OrdinalIgnoreCase) ? TemplateNodeKind.Loop : TemplateNodeKind.Condition; + if (blocks.Count == 0 || blocks.Peek().Node.Kind != expected || (isElse && blocks.Peek().InAlternative)) + { + AddDiagnostic(diagnostics, source, ref lineStarts, start, "Unexpected block directive: " + command); + active.Add(new TemplateNode { Kind = TemplateNodeKind.Literal, Text = source.Substring(start, offset - start), Position = start }); + continue; + } + BlockFrame frame = blocks.Peek(); + if (isElse) + { + frame.Node.Children = active.ToArray(); + frame.InAlternative = true; + active = new List(); + } + else + { + if (frame.InAlternative) frame.Node.Alternative = active.ToArray(); + else frame.Node.Children = active.ToArray(); + blocks.Pop(); + active = frame.Parent; + } + continue; + } + TemplateNode node = new TemplateNode { Kind = TemplateNodeKind.Value, Text = command, Position = start }; + int open = directive == null ? -1 : directive.IndexOf('('); + if (open >= 0 && directive.EndsWith(")", StringComparison.Ordinal)) + { + string keyword = directive.Substring(0, open).Trim(); + string argument = directive.Substring(open + 1, directive.Length - open - 2).Trim(); + if (keyword.Equals("foreach", StringComparison.OrdinalIgnoreCase) && IsPath(argument)) + { + node.Kind = TemplateNodeKind.Loop; + node.Path = new PropertyPath(argument); + } + else if (keyword.Equals("if", StringComparison.OrdinalIgnoreCase)) + { + int comparison = argument.IndexOfAny(new[] { '=', '!', '>', '<' }); + if (comparison > 0) + { + string path = argument.Substring(0, comparison).Trim(); + int length = comparison + 1 < argument.Length && argument[comparison + 1] == '=' ? 2 : 1; + string operation = argument.Substring(comparison, length); + string value = argument.Substring(comparison + length).Trim(); + if (IsPath(path) && value.Length > 0 && (operation == "==" || operation == "!=" || operation == ">" || operation == "<" || operation == ">=" || operation == "<=")) + { + node.Kind = TemplateNodeKind.Condition; + node.Path = new PropertyPath(path); + node.Operator = operation; + node.Comparison = value; + } + } + } + } + active.Add(node); + if (node.Kind == TemplateNodeKind.Loop || node.Kind == TemplateNodeKind.Condition) + { + blocks.Push(new BlockFrame { Node = node, Parent = active }); + maximumDepth = Math.Max(maximumDepth, blocks.Count); + active = new List(); + } + else + { + if (directive != null) + AddDiagnostic(diagnostics, source, ref lineStarts, start, "Invalid block directive: " + command); + int colon = command.IndexOf(':'); + string target = colon > 0 ? command.Substring(0, colon).Trim() : command; + node.Format = colon > 0 ? command.Substring(colon + 1).Trim() : string.Empty; + node.Path = new PropertyPath(target); + node.Expression = EngineExpressionEvaluator.Prepare(target); + if (node.Expression != null && node.Expression.Function == "calc" && node.Expression.Instructions == null) + AddDiagnostic(diagnostics, source, ref lineStarts, start, "Invalid arithmetic expression."); + } + } + while (blocks.Count > 0) { - key++; - string refKey = "RP_" + key; - templatedContent.ReplaceCodes.Add(CreateReplaceCode(refKey, m.Groups[1].Value)); - return refKey; - }); - - return templatedContent; + BlockFrame frame = blocks.Pop(); + AddDiagnostic(diagnostics, source, ref lineStarts, frame.Node.Position, "Unclosed block: " + frame.Node.Text); + if (frame.InAlternative) frame.Node.Alternative = active.ToArray(); + else frame.Node.Children = active.ToArray(); + active = frame.Parent; + } + return new EngineRunnerTemplate { Template = source, MaximumDepth = maximumDepth, Nodes = roots.ToArray(), Diagnostics = diagnostics.ToArray() }; } - private static ReplaceCode CreateReplaceCode(string replaceRef, string replaceCommand) + private static bool IsPath(string path) { - string command = replaceCommand?.Trim() ?? string.Empty; - ParseReplaceCommand(command, out string targetPropertyName, out string formattingCommand); - - return new ReplaceCode + if (path.Length == 0) return false; + for (int i = 0; i < path.Length; i++) { - ReplaceRef = replaceRef, - ReplaceCommand = command, - TargetPropertyName = targetPropertyName, - FormattingCommand = formattingCommand - }; + char character = path[i]; + if (!char.IsLetterOrDigit(character) && character != '_' && character != '.') return false; + } + return true; } - private static void ParseReplaceCommand(string replaceCommand, out string targetPropertyName, out string formattingCommand) + private static void AddDiagnostic(List diagnostics, string source, ref List lineStarts, int position, string message) { - if (string.IsNullOrEmpty(replaceCommand)) + if (lineStarts == null) { - 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; + lineStarts = new List { 0 }; + for (int i = 0; i < source.Length; i++) + if (source[i] == '\n') lineStarts.Add(i + 1); } + int line = lineStarts.BinarySearch(position); + if (line < 0) line = ~line - 1; + diagnostics.Add(new TemplateDiagnostic { Message = message, Position = position, Line = line + 1, Column = position - lineStarts[line] + 1 }); + } - targetPropertyName = replaceCommand.Trim(); - formattingCommand = string.Empty; + private class BlockFrame + { + public TemplateNode Node { get; set; } + public List Parent { get; set; } + public bool InAlternative { get; set; } } } } diff --git a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs index 9508349..fc54993 100644 --- a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs +++ b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs @@ -3,131 +3,116 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; +using System.IO; 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() + public static string Render(T record, EngineRunnerTemplate template, Dictionary parameters = null, TemplateMapperOptions options = null) where T : class { - 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++) + StringBuilder output = new StringBuilder(Math.Min(template.Template.Length, 4096)); + using (StringWriter writer = new StringWriter(output, CultureInfo.InvariantCulture)) { - 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; - } + RenderTo(writer, record, typeof(T), template, parameters, options); + return output.ToString(); + } + } - bool conditionPassed = property.IsPropertyValueConditionPassed(ifCondition.IfOperationValue, ifCondition.IfOperationType); - string replacement; + public static void RenderTo( + TextWriter writer, + object record, + Type type, + EngineRunnerTemplate template, + Dictionary parameters, + TemplateMapperOptions options) + { + TemplateMapperOptions activeOptions = (options ?? new TemplateMapperOptions()).Snapshot(); + CheckSource(template.Template, activeOptions); + CheckTemplate(template, activeOptions); + EngineRenderContext context = new EngineRenderContext(writer, activeOptions); + RenderNodes(template.Nodes, new RenderScope(record, type, parameters, false, activeOptions) { Context = context }, context, 0); + } - 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; - } + internal static void CheckSource(string source, TemplateMapperOptions options) + { + options.CancellationToken.ThrowIfCancellationRequested(); + if (options.MaximumTemplateCharacters > 0 && source.Length > options.MaximumTemplateCharacters) + throw new TemplateLimitExceededException("Template source length limit exceeded."); + } - result.ReplaceFirstOccurrence(ifCondition.ReplaceRef, replacement); + internal static void CheckTemplate(EngineRunnerTemplate template, TemplateMapperOptions options) + { + options.CancellationToken.ThrowIfCancellationRequested(); + if (options.MaximumNestingDepth > 0 && template.MaximumDepth > options.MaximumNestingDepth) + throw new TemplateLimitExceededException("Template nesting limit exceeded."); + if (options.StrictMode && template.Diagnostics.Length > 0) + { + TemplateDiagnostic diagnostic = template.Diagnostics[0]; + throw new FormatException(diagnostic.Message + " Line " + diagnostic.Line + ", column " + diagnostic.Column + "."); } + } - List loopCodes = activeTemplate.ReplaceObjLoopCodes; - for (int loopIndex = 0; loopIndex < loopCodes.Count; loopIndex++) + private static void RenderNodes(TemplateNode[] nodes, RenderScope scope, EngineRenderContext context, int depth) + { + context.Check(depth); + for (int i = 0; i < nodes.Length; i++) { - ReplaceObjLoopCode objLoop = loopCodes[loopIndex]; - if (!EnginePropertyResolver.TryResolveProperty(propMap, objLoop.TargetObjectName, out ExtractedObjProperty targetObj) || !(targetObj.OriginalValue is IEnumerable enumerable)) + context.Check(depth); + TemplateNode node = nodes[i]; + if (node.Kind == TemplateNodeKind.Literal) { - result.ReplaceFirstOccurrence(objLoop.ReplaceRef, string.Empty); + context.Write(node.Text); continue; } - - StringBuilder loopResult = new StringBuilder(); - List objLoopReplaceCodes = objLoop.ReplaceObjCodes; - - foreach (object row in enumerable) + if (node.Kind == TemplateNodeKind.Value && scope.IsRow && node.Path.Text == ".") { - 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++) + ExtractedObjProperty current = new ExtractedObjProperty { Name = ".", Type = scope.Type, OriginalValue = scope.Model }; + context.Write(current.GetPropertyDisplayString(node.Format, context.Options)); + continue; + } + bool found = EnginePropertyResolver.TryResolveProperty(scope, node.Path, out ExtractedObjProperty property); + if (node.Kind == TemplateNodeKind.Condition) + { + if (!found) { - 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 (context.Options.StrictMode) throw new FormatException("Unknown condition property: " + node.Path.Text); + context.Write("[IF-CONDITION EXCEPTION]: unrecognized property: [" + node.Path.Text + "]"); + continue; + } + bool passed = property.IsPropertyValueConditionPassed(node.Comparison, node.Operator, context); + // Preserve eager getter evaluation on branch entry for existing callers. + if (!passed && node.Alternative.Length == 0) continue; + RenderScope branch = context.Options.LazyPropertyAccess ? scope : new RenderScope(scope.Model, scope.Type, scope.Parameters, scope.IsRow, context.Options) { Context = context }; + RenderNodes(passed ? node.Children : node.Alternative, branch, context, depth + 1); + } + else if (node.Kind == TemplateNodeKind.Loop) + { + if (found && property.OriginalValue is IEnumerable rows) + { + foreach (object row in rows) { - 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 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); + context.CountIteration(); + RenderScope child = new RenderScope(row, row == null ? typeof(object) : row.GetType(), null, true, context.Options) { Context = context }; + RenderNodes(node.Children, child, context, depth + 1); } } - - loopResult.Append(activeRow); + else if (!found && context.Options.StrictMode) + throw new FormatException("Unknown collection: " + node.Path.Text); } - - 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 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 if (found) + context.Write(property.GetPropertyDisplayString(node.Format, context.Options)); + else if (EngineExpressionEvaluator.TryEvaluate(node.Expression, scope, out ExtractedObjProperty value, out bool emptyOnFailure, out bool isExpression)) + context.Write(value.GetPropertyDisplayString(node.Format, context.Options)); else - result.ReplaceFirstOccurrence(replaceCode.ReplaceRef, "{{ " + replaceCode.ReplaceCommand + " }}"); + { + if (context.Options.StrictMode) throw new FormatException("Unable to evaluate: " + node.Path.Text); + if (!(isExpression && emptyOnFailure)) + context.Write(scope.IsRow ? node.Text : "{{ " + node.Text + " }}"); + } } - - return result.ToString(); } } } diff --git a/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs b/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs index 33917ef..0072504 100644 --- a/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs +++ b/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs @@ -1,6 +1,7 @@ using ObjectSemantics.NET.Engine.Models; using System; -using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; @@ -9,7 +10,7 @@ namespace ObjectSemantics.NET.Engine { internal static class EngineTypeMetadataCache { - private static readonly ConcurrentDictionary TypeCacheMap = new ConcurrentDictionary(); + private static readonly ConditionalWeakTable> TypeCacheMap = new ConditionalWeakTable>(); private static readonly TypePropertyCache EmptyTypePropertyCache = new TypePropertyCache(Array.Empty(), new Dictionary(StringComparer.OrdinalIgnoreCase)); @@ -62,7 +63,7 @@ private static TypePropertyCache GetTypePropertyCache(Type type) if (type == null) return EmptyTypePropertyCache; - return TypeCacheMap.GetOrAdd(type, BuildTypePropertyCache); + return TypeCacheMap.GetValue(type, key => new Lazy(() => BuildTypePropertyCache(key), LazyThreadSafetyMode.ExecutionAndPublication)).Value; } private static TypePropertyCache BuildTypePropertyCache(Type type) @@ -105,7 +106,7 @@ private static Func CreatePropertyGetter(Type declaringType, Pro } } - internal sealed class PropertyAccessor + internal class PropertyAccessor { public PropertyAccessor(string name, Type propertyType, Func getter) { @@ -119,7 +120,7 @@ public PropertyAccessor(string name, Type propertyType, Func get public Func Getter { get; } } - internal sealed class TypePropertyCache + internal class TypePropertyCache { public TypePropertyCache(PropertyAccessor[] accessors, Dictionary propertyMap) { diff --git a/ObjectSemantics.NET/Engine/Extensions/ExtractedObjPropertyExtensions.cs b/ObjectSemantics.NET/Engine/Extensions/ExtractedObjPropertyExtensions.cs index 3ef9242..7b10b5a 100644 --- a/ObjectSemantics.NET/Engine/Extensions/ExtractedObjPropertyExtensions.cs +++ b/ObjectSemantics.NET/Engine/Extensions/ExtractedObjPropertyExtensions.cs @@ -1,8 +1,7 @@ -using ObjectSemantics.NET.Engine.Models; +using ObjectSemantics.NET.Engine.Models; using System; -using System.Collections.Generic; +using System.Collections; using System.Globalization; -using System.Linq; using System.Security; namespace ObjectSemantics.NET.Engine.Extensions @@ -26,32 +25,22 @@ private static string GetAppliedPropertyFormatting(this ExtractedObjProperty p, if (string.IsNullOrEmpty(customFormat) || p.OriginalValue == null) return p.StringFormatted; - Type t = p.Type; - string val = p.StringFormatted; + Type t = Nullable.GetUnderlyingType(p.Type) ?? p.Type; // avoid repeated ToLower calls string fmt = customFormat.Trim(); // handle numeric and datetime formats first try { - if (t == typeof(int) || t == typeof(int?)) - return int.Parse(val, CultureInfo.InvariantCulture).ToString(fmt, CultureInfo.InvariantCulture); - if (t == typeof(double) || t == typeof(double?)) - return double.Parse(val, CultureInfo.InvariantCulture).ToString(fmt, CultureInfo.InvariantCulture); - if (t == typeof(long) || t == typeof(long?)) - return long.Parse(val, CultureInfo.InvariantCulture).ToString(fmt, CultureInfo.InvariantCulture); - if (t == typeof(float) || t == typeof(float?)) - return float.Parse(val, CultureInfo.InvariantCulture).ToString(fmt, CultureInfo.InvariantCulture); - if (t == typeof(decimal) || t == typeof(decimal?)) - return decimal.Parse(val, CultureInfo.InvariantCulture).ToString(fmt, CultureInfo.InvariantCulture); - if (t == typeof(DateTime) || t == typeof(DateTime?)) - return DateTime.Parse(val, CultureInfo.InvariantCulture).ToString(fmt, CultureInfo.InvariantCulture); + if (t == typeof(int) || t == typeof(double) || t == typeof(long) || t == typeof(float) || t == typeof(decimal) || t == typeof(DateTime)) + return ((IFormattable)p.OriginalValue).ToString(fmt, CultureInfo.InvariantCulture); } - catch + catch (FormatException) { - // fall through if invalid format + // Preserve the existing fallback for unsupported format strings. } + string val = p.StringFormatted; // custom string-based formats (single switch to avoid multiple ToLower() checks) switch (fmt.ToLowerInvariant()) { @@ -74,15 +63,21 @@ private static T GetConvertibleValue(string value) where T : IConvertible return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); } - public static bool IsPropertyValueConditionPassed(this ExtractedObjProperty property, string valueComparer, string criteria) + public static bool IsPropertyValueConditionPassed(this ExtractedObjProperty property, string valueComparer, string criteria, EngineRenderContext context) { if (property == null) return false; try { - Type t = property.Type; + Type nullableType = Nullable.GetUnderlyingType(property.Type); + Type t = nullableType ?? property.Type; object original = property.OriginalValue; + if (nullableType != null && (original == null || string.Equals(valueComparer.Trim(), "null", StringComparison.OrdinalIgnoreCase))) + { + bool bothNull = original == null && string.Equals(valueComparer.Trim(), "null", StringComparison.OrdinalIgnoreCase); + return criteria == "==" ? bothNull : criteria == "!=" && !bothNull; + } string crit = criteria?.Trim() ?? string.Empty; if (t == typeof(string)) @@ -100,7 +95,23 @@ public static bool IsPropertyValueConditionPassed(this ExtractedObjProperty prop } } - if (t == typeof(int) || t == typeof(double) || t == typeof(long) || t == typeof(float) || t == typeof(decimal)) + if (t == typeof(int) || t == typeof(long) || t == typeof(decimal)) + { + decimal left = Convert.ToDecimal(original ?? 0, CultureInfo.InvariantCulture); + decimal right = GetConvertibleValue(valueComparer); + switch (crit) + { + case "==": return left == right; + case "!=": return left != right; + case ">": return left > right; + case ">=": return left >= right; + case "<": return left < right; + case "<=": return left <= right; + default: return false; + } + } + + if (t == typeof(double) || t == typeof(float)) { double v1 = Convert.ToDouble(original ?? 0, CultureInfo.InvariantCulture); double v2 = Convert.ToDouble(GetConvertibleValue(valueComparer), CultureInfo.InvariantCulture); @@ -143,7 +154,16 @@ public static bool IsPropertyValueConditionPassed(this ExtractedObjProperty prop if (property.IsEnumerableObject) { - int v1 = original is IEnumerable enumerable ? enumerable.Count() : 0; + long v1 = 0; + if (original is ICollection collection) v1 = collection.Count; + else if (original is IEnumerable enumerable) + { + foreach (object item in enumerable) + { + context.CountIteration(); + v1++; + } + } double v2 = Convert.ToDouble(GetConvertibleValue(valueComparer), CultureInfo.InvariantCulture); switch (crit) @@ -160,7 +180,7 @@ public static bool IsPropertyValueConditionPassed(this ExtractedObjProperty prop return false; } - catch + catch (Exception exception) when (!(exception is OperationCanceledException) && !(exception is TemplateLimitExceededException)) { return false; } diff --git a/ObjectSemantics.NET/Engine/Extensions/ReplaceCodeExtensions.cs b/ObjectSemantics.NET/Engine/Extensions/ReplaceCodeExtensions.cs deleted file mode 100644 index 9595e26..0000000 --- a/ObjectSemantics.NET/Engine/Extensions/ReplaceCodeExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using ObjectSemantics.NET.Engine.Models; - -namespace ObjectSemantics.NET.Engine.Extensions -{ - internal static class ReplaceCodeExtensions - { - public static string GetTargetPropertyName(this ReplaceCode code) - { - if (code == null) return string.Empty; - - if (string.IsNullOrEmpty(code.ReplaceCommand)) - return string.Empty; - - int colonIndex = code.ReplaceCommand.IndexOf(':'); - return colonIndex > 0 ? code.ReplaceCommand.Substring(0, colonIndex).Trim() : code.ReplaceCommand.Trim(); - } - - public static string GetFormattingCommand(this ReplaceCode code) - { - if (code == null) return string.Empty; - - if (string.IsNullOrEmpty(code.ReplaceCommand)) - return string.Empty; - - // Find the colon separating the target and the formatting command - int colonIndex = code.ReplaceCommand.IndexOf(':'); - if (colonIndex < 0 || colonIndex >= code.ReplaceCommand.Length - 1) - return string.Empty; - - // Extract everything after the first colon - string afterColon = code.ReplaceCommand.Substring(colonIndex + 1).Trim(); - - return afterColon; - } - } -} diff --git a/ObjectSemantics.NET/Engine/Models/EngineRunnerTemplate.cs b/ObjectSemantics.NET/Engine/Models/EngineRunnerTemplate.cs index 6f3a1a2..5420d58 100644 --- a/ObjectSemantics.NET/Engine/Models/EngineRunnerTemplate.cs +++ b/ObjectSemantics.NET/Engine/Models/EngineRunnerTemplate.cs @@ -1,12 +1,58 @@ -using System.Collections.Generic; +using System; namespace ObjectSemantics.NET.Engine.Models { internal class EngineRunnerTemplate { public string Template { get; set; } - public List ReplaceObjLoopCodes { get; set; } = new List(); - public List ReplaceCodes { get; set; } = new List(); - public List ReplaceIfConditionCodes { get; set; } = new List(); + public int MaximumDepth { get; set; } + public TemplateNode[] Nodes { get; set; } = Array.Empty(); + public TemplateDiagnostic[] Diagnostics { get; set; } = Array.Empty(); + } + + internal enum TemplateNodeKind + { + Literal, + Value, + Condition, + Loop + } + + internal class TemplateNode + { + public TemplateNodeKind Kind { get; set; } + public string Text { get; set; } + public string Format { get; set; } + public string Operator { get; set; } + public string Comparison { get; set; } + public int Position { get; set; } + public PropertyPath Path { get; set; } + public EngineExpressionEvaluator.ExpressionPlan Expression { get; set; } + public TemplateNode[] Children { get; set; } = Array.Empty(); + public TemplateNode[] Alternative { get; set; } = Array.Empty(); + } + + internal class PropertyPath + { + public PropertyPath(string text) + { + Text = text.Trim(); + int dot = Text.IndexOf('.'); + Root = dot < 0 ? Text : Text.Substring(0, dot).Trim(); + string tail = dot < 0 ? string.Empty : Text.Substring(dot + 1); + string[] segments = tail.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + int count = 0; + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i].Trim(); + if (segment.Length > 0) segments[count++] = segment; + } + if (count != segments.Length) Array.Resize(ref segments, count); + Segments = segments; + } + + public string Text { get; set; } + public string Root { get; set; } + public string[] Segments { get; set; } } } diff --git a/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs b/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs deleted file mode 100644 index 282a265..0000000 --- a/ObjectSemantics.NET/Engine/Models/ReplaceCode.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ObjectSemantics.NET.Engine.Models -{ - internal class ReplaceCode - { - public string ReplaceRef { get; set; } - public string ReplaceCommand { get; set; } - public string TargetPropertyName { get; set; } - public string FormattingCommand { get; set; } - } -} diff --git a/ObjectSemantics.NET/Engine/Models/ReplaceIfOperationCode.cs b/ObjectSemantics.NET/Engine/Models/ReplaceIfOperationCode.cs deleted file mode 100644 index 59a8949..0000000 --- a/ObjectSemantics.NET/Engine/Models/ReplaceIfOperationCode.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ObjectSemantics.NET.Engine.Models -{ - internal class ReplaceIfOperationCode - { - public string IfPropertyName { get; set; } - public string IfOperationType { get; set; } - public string IfOperationValue { get; set; } - public string ReplaceRef { get; set; } - public string IfOperationTrueTemplate { get; set; } = string.Empty; - public string IfOperationFalseTemplate { get; set; } = string.Empty; - } -} \ No newline at end of file diff --git a/ObjectSemantics.NET/Engine/Models/ReplaceObjLoopCode.cs b/ObjectSemantics.NET/Engine/Models/ReplaceObjLoopCode.cs deleted file mode 100644 index 15b15d4..0000000 --- a/ObjectSemantics.NET/Engine/Models/ReplaceObjLoopCode.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; - -namespace ObjectSemantics.NET.Engine.Models -{ - internal class ReplaceObjLoopCode - { - public string ReplaceRef { get; set; } - public string TargetObjectName { get; set; } - public string ObjLoopTemplate { get; set; } - public List ReplaceObjCodes { get; set; } = new List(); - } -} diff --git a/ObjectSemantics.NET/ObjectSemantics.NET.csproj b/ObjectSemantics.NET/ObjectSemantics.NET.csproj index 852467b..cc1d799 100644 --- a/ObjectSemantics.NET/ObjectSemantics.NET.csproj +++ b/ObjectSemantics.NET/ObjectSemantics.NET.csproj @@ -9,13 +9,11 @@ https://github.com/swagfin/ObjectSemantics.NET git - . Added support for single arrays Loop Support -. Added encoding string formattings - ToMD5 - ToBase64 - FromBase64 -. Added template extension method to allow mapping directly from Template - 7.1.0 + Version 8.0.0 introduces structured template parsing and ordered rendering, reusable compiled templates, writer output, syntax diagnostics, strict mode, cancellation, and execution limits. +Performance improvements include prepared expressions and property paths, incremental bounded caching, direct typed formatting, and opt-in lazy property access and streaming aggregates. +Fixes include replacement-marker collisions, nested control blocks, nullable conditions, precise integral/decimal comparisons, date precision, value-type collection conditions, and expression overflow handling. +Existing Map APIs and .NET Standard 2.0 support remain. Review README compatibility notes for source-order execution, corrected output, and the default nesting limit of 128. + 8.0.0 $(Version) $(Version) false diff --git a/ObjectSemantics.NET/Properties/AssemblyInfo.cs b/ObjectSemantics.NET/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c0f1a94 --- /dev/null +++ b/ObjectSemantics.NET/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ObjectSemantics.NET.Tests")] +[assembly: InternalsVisibleTo("ObjectSemantics.NET.Benchmarks")] diff --git a/ObjectSemantics.NET/TemplateDiagnostic.cs b/ObjectSemantics.NET/TemplateDiagnostic.cs new file mode 100644 index 0000000..4b831b4 --- /dev/null +++ b/ObjectSemantics.NET/TemplateDiagnostic.cs @@ -0,0 +1,10 @@ +namespace ObjectSemantics.NET +{ + public class TemplateDiagnostic + { + public string Message { get; set; } + public int Position { get; set; } + public int Line { get; set; } + public int Column { get; set; } + } +} diff --git a/ObjectSemantics.NET/TemplateLimitExceededException.cs b/ObjectSemantics.NET/TemplateLimitExceededException.cs new file mode 100644 index 0000000..506691c --- /dev/null +++ b/ObjectSemantics.NET/TemplateLimitExceededException.cs @@ -0,0 +1,11 @@ +using System; + +namespace ObjectSemantics.NET +{ + public class TemplateLimitExceededException : InvalidOperationException + { + public TemplateLimitExceededException(string message) : base(message) + { + } + } +} diff --git a/ObjectSemantics.NET/TemplateMapper.cs b/ObjectSemantics.NET/TemplateMapper.cs index 947c636..8d7536a 100644 --- a/ObjectSemantics.NET/TemplateMapper.cs +++ b/ObjectSemantics.NET/TemplateMapper.cs @@ -1,4 +1,4 @@ -using ObjectSemantics.NET.Engine; +using ObjectSemantics.NET.Engine; using ObjectSemantics.NET.Engine.Models; using System; using System.Collections.Generic; @@ -7,6 +7,29 @@ namespace ObjectSemantics.NET { public static class TemplateMapper { + /// Prepare a reusable template. Options validate compilation; pass render options separately. + public static CompiledTemplate Compile(string template, TemplateMapperOptions options = null) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + TemplateMapperOptions activeOptions = (options ?? new TemplateMapperOptions()).Snapshot(); + EngineTemplateRenderer.CheckSource(template, activeOptions); + EngineRunnerTemplate parsed = EngineTemplateCache.GetOrAdd(template, EngineTemplateParser.Parse); + EngineTemplateRenderer.CheckTemplate(parsed, activeOptions); + return new CompiledTemplate(parsed); + } + + /// Validate syntax without evaluating model getters. Missing model values are checked by strict rendering. + public static IReadOnlyList Validate(string template) + { + return Compile(template).Diagnostics; + } + + /// Replaces the process-wide template cache. Existing renders remain valid. + public static void ConfigureCache(int capacity = 2048, long maximumSourceCharacters = 16777216) + { + EngineTemplateCache.Configure(capacity, maximumSourceCharacters); + } + /// /// Generate a mapped string from string /// @@ -35,9 +58,10 @@ public static class TemplateMapper { if (record == null) return string.Empty; if (template == null) throw new Exception("Template Object can't be NULL"); - if (options == null) options = new TemplateMapperOptions(); - EngineRunnerTemplate runnerTemplate = EngineAlgorithim.GenerateRunnerTemplate(template); - return runnerTemplate == null ? throw new Exception($"Error Mapping!") : EngineAlgorithim.GenerateFromTemplate(record, runnerTemplate, additionalKeyValues, options); + options = (options ?? new TemplateMapperOptions()).Snapshot(); + EngineTemplateRenderer.CheckSource(template, options); + EngineRunnerTemplate runnerTemplate = EngineTemplateCache.GetOrAdd(template, EngineTemplateParser.Parse); + return EngineTemplateRenderer.Render(record, runnerTemplate, additionalKeyValues, options); } } -} \ No newline at end of file +} diff --git a/ObjectSemantics.NET/TemplateMapperOptions.cs b/ObjectSemantics.NET/TemplateMapperOptions.cs index afe5d2f..ff2d245 100644 --- a/ObjectSemantics.NET/TemplateMapperOptions.cs +++ b/ObjectSemantics.NET/TemplateMapperOptions.cs @@ -1,10 +1,35 @@ -namespace ObjectSemantics.NET +using System; +using System.Threading; + +namespace ObjectSemantics.NET { public class TemplateMapperOptions { - /// - /// This will apply XML Character Escape on invalid characters in a Property Value String with their valid XML Equivalent - /// - public bool XmlCharEscaping { get; set; } = false; + /// Read only requested top-level properties, once per scope. Opt in for models with pure getters. + public bool LazyPropertyAccess { get; set; } + /// Traverse aggregate paths without intermediate lists. Getter traversal becomes depth-first. + public bool UseStreamingEvaluation { get; set; } + /// Throw for syntax diagnostics, missing values, and invalid expressions. + public bool StrictMode { get; set; } + /// Maximum block or recursive collection traversal depth. Zero disables the limit. + public int MaximumNestingDepth { get; set; } = 128; + /// Maximum source length in UTF-16 characters. Zero disables the limit. + public int MaximumTemplateCharacters { get; set; } + /// Total enumerated items across loops, conditions, and expressions. Zero disables the limit. + public long MaximumIterations { get; set; } + /// Maximum output length in UTF-16 characters. Zero disables the limit. + public long MaximumOutputCharacters { get; set; } + public CancellationToken CancellationToken { get; set; } + /// Escape XML special characters in formatted property values. + public bool XmlCharEscaping { get; set; } + + internal TemplateMapperOptions Snapshot() + { + if (MaximumNestingDepth < 0) throw new ArgumentOutOfRangeException(nameof(MaximumNestingDepth)); + if (MaximumTemplateCharacters < 0) throw new ArgumentOutOfRangeException(nameof(MaximumTemplateCharacters)); + if (MaximumIterations < 0) throw new ArgumentOutOfRangeException(nameof(MaximumIterations)); + if (MaximumOutputCharacters < 0) throw new ArgumentOutOfRangeException(nameof(MaximumOutputCharacters)); + return (TemplateMapperOptions)MemberwiseClone(); + } } } diff --git a/README.md b/README.md index 0fdc325..d8c4055 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,384 @@ +
+ # ObjectSemantics.NET + +### Your objects. Your templates. Messages that feel personal. + +Turn everyday .NET data into payment reminders, order confirmations, receipts, and reports—with a small, readable template language. + +[![NuGet](https://img.shields.io/nuget/v/ObjectSemantics.NET.svg)](https://www.nuget.org/packages/ObjectSemantics.NET) +[![Target](https://img.shields.io/badge/.NET%20Standard-2.0-512BD4)](ObjectSemantics.NET/ObjectSemantics.NET.csproj) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![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) -Object-to-template mapping for .NET with nested property support, loops, conditions, formatting, and lightweight calculations. +**Nested properties · Loops · Conditions · Calculations · Reusable templates** + +[Get started](#get-started) · [Everyday examples](#everyday-examples) · [Performance](#performance) · [Production controls](#production-controls) + +
+ +--- + +A customer has an outstanding balance. An order has five line items. A receipt needs a different message when payment is complete. -## 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 +ObjectSemantics.NET lets you express those messages in templates and fill them from your existing objects. Keep the wording separate from application code, format values where they appear, and reuse the same template for the next customer. -## 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 +```text +Hi {{ Name }}, your balance is KES {{ Balance:N2 }}. Due {{ DueDate:dd MMM }}. +``` + +**Becomes:** + +```text +Hi Amina, your balance is KES 4,500.00. Due 05 Oct. +``` -## Installation -Install from [NuGet](https://www.nuget.org/packages/ObjectSemantics.NET): +| When you need to… | The library gives you… | +|---|---| +| Personalize an SMS or email | Readable placeholders mapped to object properties | +| Build a receipt with a changing number of items | Collection loops and per-item formatting | +| Show “Paid” or “Payment due” | Conditional blocks, including nested conditions | +| Calculate a subtotal or display a balance | Aggregates and arithmetic expressions | +| Generate the same message for many customers | Cached parsing and reusable compiled templates | +| Catch mistakes before sending | Syntax diagnostics and optional strict rendering | -```powershell -Install-Package ObjectSemantics.NET +The core library targets **.NET Standard 2.0** and has **no explicit third-party package dependencies**. It renders text that your application can send, save, or display. + +## Get started + +```sh +dotnet add package ObjectSemantics.NET ``` -## Quick Start +Create a model, write a template, and call `Map()`: + ```csharp +using System; using ObjectSemantics.NET; -var person = new Person { Name = "John Doe" }; -string output = person.Map("Hello {{ Name }}"); -// Hello John Doe +Customer customer = new Customer +{ + Name = "Amina", + Balance = 4500m, + DueDate = new DateTime(2026, 10, 5) +}; + +string template = "Hi {{ Name }}, your balance is KES {{ Balance:N2 }}. Due {{ DueDate:dd MMM }}."; +string message = customer.Map(template); + +Console.WriteLine(message); + +public class Customer +{ + public string Name { get; set; } + public decimal Balance { get; set; } + public DateTime DueDate { get; set; } +} +``` + +Prefer starting from the template? Both forms work: + +```csharp +string message = customer.Map("Hello {{ Name }}!"); +string sameMessage = "Hello {{ Name }}!".Map(customer); +``` + +## Everyday examples + +### 1. Send the right payment reminder + +Use the `customer` from the quick start. One template handles both outstanding balances and settled accounts: + +```csharp +string template = "{{ #if(Balance > 0) }}Hi {{ Name }}, please pay KES {{ Balance:N2 }} by {{ DueDate:dd MMM }}.{{ #else }}Thanks {{ Name }}! Your account is fully paid.{{ #endif }}"; +string message = customer.Map(template); +``` + +```text +Hi Amina, please pay KES 4,500.00 by 05 Oct. +``` + +Set `customer.Balance` to `0m`, and the same template produces: + +```text +Thanks Amina! Your account is fully paid. ``` -## Template Examples -### Nested mapping +### 2. Turn an order into a receipt + +Read the customer's name from a nested object, loop over purchased items, calculate each line, and add up the total. + ```csharp -var payment = new CustomerPayment +using System.Collections.Generic; + +SalesOrder order = new SalesOrder { - Amount = 100000000, - Customer = new Customer + Number = "ORD-1042", + Customer = customer, + IsPaid = true, + Items = new List { - CompanyName = "CRUDSOFT TECHNOLOGIES" + new OrderItem { Name = "Notebook", Quantity = 2, UnitPrice = 250m }, + new OrderItem { Name = "Pen", Quantity = 3, UnitPrice = 50m } } }; -string result = payment.Map("Paid Amount: {{ Amount:N2 }} By {{ Customer.CompanyName }}"); -// Paid Amount: 100,000,000.00 By CRUDSOFT TECHNOLOGIES +string template = @"Order {{ Number }} for {{ Customer.Name }} +{{ #foreach(Items) }}- {{ Quantity }} x {{ Name }}: KES {{ __calc(Quantity * UnitPrice):N2 }} +{{ #endforeach }}Total: KES {{ __sum(Items.LineTotal):N2 }} +{{ #if(IsPaid == true) }}Paid. Thank you for shopping with us!{{ #else }}Payment is due on collection.{{ #endif }}"; + +string receipt = order.Map(template); ``` -### Loop + format +```text +Order ORD-1042 for Amina +- 2 x Notebook: KES 500.00 +- 3 x Pen: KES 150.00 +Total: KES 650.00 +Paid. Thank you for shopping with us! +``` + +
+The order models + +Reuse the `Customer` class from the quick start. + ```csharp -string template = "{{ #foreach(Items) }}[{{ Quantity }}x{{ Name }}={{ LineTotal:N2 }}]{{ #endforeach }}"; +public class SalesOrder +{ + public string Number { get; set; } + public Customer Customer { get; set; } + public bool IsPaid { get; set; } + public List Items { get; set; } +} + +public class OrderItem +{ + public string Name { get; set; } + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal LineTotal { get { return Quantity * UnitPrice; } } +} ``` -### Condition +
+ +### 3. Add campaign details without changing your model + +Supply extra values for a branch name, support number, campaign code, or other information that belongs to the message. + ```csharp -string template = "{{ #if(IsPaid == true) }}PAID{{ #else }}UNPAID{{ #endif }}"; +Dictionary extras = new Dictionary +{ + ["BranchName"] = "Westlands", + ["SupportNumber"] = "+254 700 000 001" +}; + +string template = "Hi {{ Name }}, your {{ BranchName }} team is here to help. Call {{ SupportNumber }}."; +string message = customer.Map(template, extras); +``` + +```text +Hi Amina, your Westlands team is here to help. Call +254 700 000 001. ``` -### Calculations +Extra keys must be unique and must not duplicate model property names. Matching is case-insensitive. + +## The template language at a glance + +| Task | Example | +|---|---| +| Read a property | `{{ Name }}` | +| Read a nested property | `{{ Customer.Name }}` | +| Format a number | `{{ Balance:N2 }}` | +| Format a date | `{{ DueDate:dd MMM yyyy }}` | +| Change text case | `{{ Name:uppercase }}` | +| Loop over objects | `{{ #foreach(Items) }}{{ Name }}{{ #endforeach }}` | +| Loop over scalar values | `{{ #foreach(Tags) }}{{ . }} {{ #endforeach }}` | +| Choose a message | `{{ #if(IsPaid == true) }}Paid{{ #else }}Due{{ #endif }}` | +| Sum projected values | `{{ __sum(Items.LineTotal):N2 }}` | +| Find an average | `{{ __avg(Items.UnitPrice):N2 }}` | +| Count non-null projected values | `{{ __count(Items.Name) }}` | +| Find a minimum or maximum | `{{ __min(Items.UnitPrice) }}` / `{{ __max(Items.UnitPrice) }}` | +| Calculate a value | `{{ __calc(Quantity * UnitPrice):N2 }}` | + +Conditions support `==`, `!=`, `>`, `>=`, `<`, and `<=`. Arithmetic supports `+`, `-`, `*`, `/`, parentheses, and unary minus. Loops and conditions can be nested; inside a loop, properties refer to the current item. + +Number and date formatting use invariant culture. String commands include `uppercase`, `lowercase`, `titlecase`, `length`, `tobase64`, and `frombase64`. Title casing uses the current culture. + +## Compile once. Personalize repeatedly. + +For a notification worker or a recurring report, retain the prepared template and supply a different model for each render: + ```csharp -// Aggregates -"{{ __sum(Customer.Payments.Amount):N2 }}" -"{{ _avg(Customer.Payments.PaidAmount):N2 }}" -"{{ __count(Customer.Payments.Amount) }}" +CompiledTemplate reminder = TemplateMapper.Compile("Hi {{ Name }}, your balance is KES {{ Balance:N2 }}."); -// Arithmetic expression -"{{ __calc(PaidAmount - Customer.CreditLimit):N2 }}" +foreach (Customer recipient in customers) +{ + string message = reminder.Render(recipient); + // Pass the message to your application's email or SMS service. +} ``` -## Documentation -Detailed wiki files are available at the Wiki Page: -- [Go to Documentation](https://github.com/swagfin/ObjectSemantics.NET/wiki) +Here, `customers` is your application's collection of `Customer` objects. Compiled templates can be shared across concurrent renders with independent models and writers. They retain template structure, not customer values. + +For large reports, write directly to a `TextWriter`: + +```csharp +reminder.RenderTo(writer, customer); +``` + +The caller supplies and owns `writer`. It remains open after rendering. A failed render can leave partial output, so use `Render()` when you need a complete string before writing anything. + +> The compilation, validation, and rendering controls below describe the current repository implementation. They may require building from source until the corresponding NuGet release is published. + +## Performance + +**Prepare the template once. Spend subsequent renders filling it with data.** + +The engine parses templates into structured nodes, prepares arithmetic expressions and property paths, caches property getters, and writes output in order. `Map()` automatically uses a bounded template cache; `Compile()` lets your application retain a template directly. + +Measured locally with BenchmarkDotNet against the earlier renderer: + +| Workload | Earlier renderer | Updated renderer | Allocation change | +|---|---:|---:|---:| +| 200 repeated placeholders | 7.77 ms | **4.07 µs** | 37.61 KB → **14.10 KB** | +| Arithmetic across 100 rows | 38.59 µs | **16.45 µs** | 227.13 KB → **87.59 KB** | + +The repeated-placeholder case exposes a particularly expensive path in the earlier renderer. These are specific workload results, not a general speed multiplier or a comparison with other libraries. + +
+Measurement details and how to reproduce + +Measurements used BenchmarkDotNet 0.15.8, its ShortRun job (one launch, three warmups, three measured iterations), an Apple M5 Pro, macOS 27.0, and .NET 10.0.8. The earlier library was revision `e463bda`, measured with the same two workload definitions before the engine changes. Results are rounded; KB means 1,024 bytes. Parser validation and whitespace compatibility received additional review after the measurements. + +Additional observations from the updated renderer: + +| Workload | Mean | Allocated per operation | +|---|---:|---:| +| Retained compiled template, 200 placeholders | 3.52 µs | 14,288 B | +| Same template written to `TextWriter.Null` | 2.49 µs | 528 B | +| Two aggregates over 100 items, default evaluation | 4.93 µs | 12,448 B | +| Same aggregates, lazy reads and streaming enabled | 4.44 µs | 7,792 B | + +Writer measurements exclude transport costs. Short runs are directional measurements; use representative application load tests to assess throughput and tail latency. + +```sh +dotnet run --project ObjectSemantics.NET.Benchmarks -c Release -- --filter '*' --job short +``` + +Omit `--job short` for a longer BenchmarkDotNet run. The suite covers warm rendering, arithmetic loops, aggregates, 4,096-template cache churn, cold parsing, compiled templates, writer output, and concurrency. Cold parsing excludes process startup and first-JIT costs; concurrent measurements include scheduling overhead. + +
+ +## Production controls + +### Catch template mistakes early + +Validate syntax before saving or publishing a template: + +```csharp +IReadOnlyList diagnostics = TemplateMapper.Validate("Hello {{ Name"); + +foreach (TemplateDiagnostic diagnostic in diagnostics) +{ + Console.WriteLine($"Line {diagnostic.Line}, column {diagnostic.Column}: {diagnostic.Message}"); +} +``` + +Validation reports syntax problems without executing model getters. Enable `StrictMode` during rendering to throw when an executed expression cannot be resolved. Missing values in an unselected branch are not evaluated. + +### Set a budget for each render + +```csharp +TemplateMapperOptions options = new TemplateMapperOptions +{ + StrictMode = true, + XmlCharEscaping = true, + MaximumTemplateCharacters = 100000, + MaximumNestingDepth = 32, + MaximumIterations = 10000, + MaximumOutputCharacters = 1000000 +}; + +string message = customer.Map("Hello {{ Name }}", options: options); +``` + +The limits above are examples to tune for your workload. You can also supply `CancellationToken`. `XmlCharEscaping` escapes special characters in formatted values; for example, `Amina & Sons` becomes `Amina & Sons`. + +| Option | Default | Purpose | +|---|---|---| +| `StrictMode` | `false` | Reject syntax diagnostics and unresolved expressions during rendering | +| `XmlCharEscaping` | `false` | Escape XML special characters in inserted values | +| `MaximumNestingDepth` | `128` | Bound block nesting and recursive collection traversal | +| `MaximumTemplateCharacters` | `0` | Bound template source length | +| `MaximumIterations` | `0` | Bound total enumerated items across loops, conditions, and expressions | +| `MaximumOutputCharacters` | `0` | Bound written output length | +| `LazyPropertyAccess` | `false` | Read only requested top-level properties, once per scope | +| `UseStreamingEvaluation` | `false` | Traverse aggregate paths without intermediate lists | + +Zero disables a limit. Character limits count UTF-16 characters. Negative limits are rejected. Options passed to `Compile()` validate compilation; pass render options separately to `Render()` or `RenderTo()`. + +For data-only models with pure getters and stable collections, opt into `LazyPropertyAccess` and `UseStreamingEvaluation` to reduce unnecessary work. These options change getter timing and traversal order. Nested getters still resolve as needed, and repeated aggregates enumerate separately. + +### Keep caching predictable + +Configure the process-wide cache during application startup: + +```csharp +TemplateMapper.ConfigureCache(capacity: 4096, maximumSourceCharacters: 33554432); +``` + +Defaults are 2,048 entries and 16,777,216 retained source characters. Entries are evicted incrementally in insertion order. Oversized templates bypass the cache. The source-character budget is not a total managed-memory limit; parsed nodes and metadata have additional overhead. Existing compiled templates remain valid when the cache is reconfigured. + +
+Execution boundaries + +Each render owns its values, scope, counters, and an options snapshot. Keep models, parameter dictionaries, and options stable while a render starts and executes. + +Cancellation is checked before/after compilation and throughout rendering and aggregate traversal. It cannot interrupt an individual parse operation or arbitrary getter, formatter, enumerator, or writer code. Output limits are checked before writes; formatting may allocate before that check. Use dedicated data-only models and suitable source limits when accepting templates from outside your application. These controls do not make application objects a security sandbox. + +
+ +## Compatibility and upgrade notes + +
+Existing templates: what stays the same, and what changes + +Existing `Map()` overloads remain available. They accept classes with parameterless constructors; the compiled API also accepts classes without one. Property matching remains case-insensitive, additional keys cannot override model properties, and XML escaping remains opt-in. + +The updated renderer executes nodes in source order. It keeps eager top-level property reads by default, with separate eager scopes for selected conditional branches. Review templates that depend on getter side effects or mutation during enumeration. + +Correctness improvements include: + +- Literal text and values containing old internal markers such as `RP_1` remain intact. Inserted values are never reparsed as template source. +- Nested blocks work in their current scope; conditions inside loops evaluate the row. Root/parent aliases are not provided. +- Formatted dates retain fractional seconds and `DateTime.Kind`. +- Nullable conditions work, and integral/decimal comparisons preserve precision. +- Collection conditions correctly count value-type collections. +- Expression overflow renders empty by default and throws `FormatException` in strict mode. +- Malformed arithmetic is rejected. Validate malformed block syntax before upgrading; its output may differ from the earlier parser. + +Retained permissive-mode rules: + +- Unknown ordinary values remain `{{ Name }}` at the root and `Name` inside loops. +- Unknown or invalid expression paths render empty. +- Empty aggregate paths render empty; null aggregate sources yield zero. +- A null arithmetic operand makes the result zero unless evaluation fails, such as division by zero. +- `__count(Items.Name)` counts non-null projected names. `__count(Items)` retains the historical result of one non-null collection object rather than the number of elements. + +
+ +## Build, test, and contribute + +The library targets .NET Standard 2.0. Tests and benchmarks require the .NET 10 SDK. + +```sh +dotnet build ObjectSemantics.NET.sln -c Release +dotnet test ObjectSemantics.NET.sln -c Release +``` -## Contributing -Contributions are welcome through issues and pull requests. +Found an edge case or have an idea? [Open an issue](https://github.com/swagfin/ObjectSemantics.NET/issues) or submit a pull request. A small template, sample model, and expected output make a great starting point. -## License -[MIT](LICENSE) +[Project wiki](https://github.com/swagfin/ObjectSemantics.NET/wiki) · [NuGet package](https://www.nuget.org/packages/ObjectSemantics.NET) · [MIT license](LICENSE) From c31f46f732dca1fe7da4c5c2eeff08fce5f4bb8c Mon Sep 17 00:00:00 2001 From: "George Njeri (Swagfin)" Date: Wed, 16 Sep 2026 22:11:54 +0300 Subject: [PATCH 2/2] chore: cleaning up orphaned code --- .../ObjectSemantics.NET.Tests.csproj | 4 -- .../PropertyNullableTests.cs | 68 ------------------- .../Engine/EngineExpressionEvaluator.cs | 17 ++--- .../Engine/EnginePropertyResolver.cs | 6 +- .../Engine/EngineRenderContext.cs | 4 +- .../Engine/EngineTemplateRenderer.cs | 2 +- .../Engine/EngineTypeMetadataCache.cs | 2 - .../Extensions/StringBuilderExtensions.cs | 3 +- .../Engine/Models/ExtractedObjProperty.cs | 9 +-- .../ObjectSemantics.NET.csproj | 6 -- 10 files changed, 15 insertions(+), 106 deletions(-) delete mode 100644 ObjectSemantics.NET.Tests/PropertyNullableTests.cs diff --git a/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj b/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj index 9000525..cabad52 100644 --- a/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj +++ b/ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj @@ -4,10 +4,6 @@ net10.0 false - - 3.0.1.1 - - 3.0.1.1 diff --git a/ObjectSemantics.NET.Tests/PropertyNullableTests.cs b/ObjectSemantics.NET.Tests/PropertyNullableTests.cs deleted file mode 100644 index fc22201..0000000 --- a/ObjectSemantics.NET.Tests/PropertyNullableTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -//using ObjectSemantics.NET.Tests.MoqModels; -//using System; -//using Xunit; - -//namespace ObjectSemantics.NET.Tests -//{ -// public class PropertyNullableTests -// { -// [Fact] -// public void Should_Map_Nullable_DateTime_Property_Given_NULL() -// { -// //Create Model -// StudentClockInDetail clockInDetails = new StudentClockInDetail { LastClockedInDate = null }; -// var template = new ObjectSemanticsTemplate -// { -// FileContents = @"Last Clocked In: {{ LastClockedInDate:yyyy-MM-dd }}" -// }; -// string generatedTemplate = template.Map(clockInDetails); -// string expectedString = "Last Clocked In: "; -// Assert.Equal(expectedString, generatedTemplate, false, true, true); -// } - -// [Fact] -// public void Should_Map_Nullable_DateTime_Property_Given_A_Value() -// { -// //Create Model -// StudentClockInDetail clockInDetails = new StudentClockInDetail { LastClockedInDate = DateTime.Now }; -// var template = new ObjectSemanticsTemplate -// { -// FileContents = @"Last Clocked In: {{ LastClockedInDate:yyyy-MM-dd }}" -// }; -// string generatedTemplate = template.Map(clockInDetails); -// string expectedString = $"Last Clocked In: {DateTime.Now:yyyy-MM-dd}"; -// Assert.Equal(expectedString, generatedTemplate, false, true, true); -// } - -// [Fact] -// public void Should_Map_Nullable_Number_Property_Given_NULL() -// { -// //Create Model -// StudentClockInDetail clockInDetails = new StudentClockInDetail { LastClockedInPoints = null }; -// var template = new ObjectSemanticsTemplate -// { -// FileContents = @"Last Clocked In Points: {{ LastClockedInPoints:N2 }}" -// }; -// string generatedTemplate = template.Map(clockInDetails); -// string expectedString = "Last Clocked In Points: "; -// Assert.Equal(expectedString, generatedTemplate, false, true, true); -// } - -// [Theory] -// [InlineData(null)] -// [InlineData(2500)] -// [InlineData(200)] -// public void Should_Map_Nullable_Number_Property_Given_A_Value(long? number) -// { -// //Create Model -// StudentClockInDetail clockInDetails = new StudentClockInDetail { LastClockedInPoints = number }; -// var template = new ObjectSemanticsTemplate -// { -// FileContents = @"Last Clocked In Points: {{ LastClockedInPoints:N2 }}" -// }; -// string generatedTemplate = template.Map(clockInDetails); -// string expectedString = $"Last Clocked In Points: {number:N2}"; -// Assert.Equal(expectedString, generatedTemplate, false, true, true); -// } -// } -//} diff --git a/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs b/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs index 7878b82..7f8af3e 100644 --- a/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs +++ b/ObjectSemantics.NET/Engine/EngineExpressionEvaluator.cs @@ -13,7 +13,6 @@ internal static class EngineExpressionEvaluator internal class ExpressionPlan { - public string Command { get; set; } public string Function { get; set; } public PropertyPath Argument { get; set; } public ExpressionToken[] Instructions { get; set; } @@ -28,7 +27,6 @@ public static ExpressionPlan Prepare(string command) return null; ExpressionPlan plan = new ExpressionPlan { - Command = command, Function = match.Groups["fn"].Value.Trim().ToLowerInvariant(), Argument = new PropertyPath(match.Groups["arg"].Value.Trim()) }; @@ -55,7 +53,6 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext isExpressionCommand = plan != null; if (plan == null) return false; - string expressionCommand = plan.Command; string fn = plan.Function; PropertyPath arg = plan.Argument; @@ -69,7 +66,7 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext renderEmptyOnFailure = true; return false; } - evaluatedProperty = CreateDecimalProperty(expressionCommand, sum); + evaluatedProperty = CreateDecimalProperty(sum); return true; case "avg": @@ -78,7 +75,7 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext renderEmptyOnFailure = true; return false; } - evaluatedProperty = CreateDecimalProperty(expressionCommand, avg); + evaluatedProperty = CreateDecimalProperty(avg); return true; case "count": @@ -89,7 +86,6 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext } evaluatedProperty = new ExtractedObjProperty { - Name = expressionCommand, Type = typeof(int), OriginalValue = count }; @@ -101,7 +97,7 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext renderEmptyOnFailure = true; return false; } - evaluatedProperty = CreateDecimalProperty(expressionCommand, min); + evaluatedProperty = CreateDecimalProperty(min); return true; case "max": @@ -110,7 +106,7 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext renderEmptyOnFailure = true; return false; } - evaluatedProperty = CreateDecimalProperty(expressionCommand, max); + evaluatedProperty = CreateDecimalProperty(max); return true; case "calc": @@ -119,7 +115,7 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext renderEmptyOnFailure = true; return false; } - evaluatedProperty = CreateDecimalProperty(expressionCommand, calcResult); + evaluatedProperty = CreateDecimalProperty(calcResult); return true; } @@ -132,11 +128,10 @@ public static bool TryEvaluate(ExpressionPlan plan, RenderScope propMap, out Ext } } - private static ExtractedObjProperty CreateDecimalProperty(string name, decimal value) + private static ExtractedObjProperty CreateDecimalProperty(decimal value) { return new ExtractedObjProperty { - Name = name, Type = typeof(decimal), OriginalValue = value }; diff --git a/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs b/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs index 7eb0b5c..fd97376 100644 --- a/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs +++ b/ObjectSemantics.NET/Engine/EnginePropertyResolver.cs @@ -22,10 +22,10 @@ public static bool TryResolveProperty(RenderScope propMap, PropertyPath property if (!propMap.TryGetValue(rootName, out ExtractedObjProperty rootProperty)) return false; - return TryResolveNestedProperty(rootProperty, propertyPath.Segments, path, out result); + return TryResolveNestedProperty(rootProperty, propertyPath.Segments, out result); } - private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string[] segments, string fullPath, out ExtractedObjProperty result) + private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, string[] segments, out ExtractedObjProperty result) { result = null; if (rootProperty == null) @@ -56,7 +56,6 @@ private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, { result = new ExtractedObjProperty { - Name = fullPath, Type = currentType, OriginalValue = nextValue }; @@ -68,6 +67,5 @@ private static bool TryResolveNestedProperty(ExtractedObjProperty rootProperty, return false; } - } } diff --git a/ObjectSemantics.NET/Engine/EngineRenderContext.cs b/ObjectSemantics.NET/Engine/EngineRenderContext.cs index fe06a61..1a231cc 100644 --- a/ObjectSemantics.NET/Engine/EngineRenderContext.cs +++ b/ObjectSemantics.NET/Engine/EngineRenderContext.cs @@ -64,7 +64,7 @@ public RenderScope(object model, Type type, Dictionary parameter { if (EngineTypeMetadataCache.TryGetPropertyAccessor(type, parameter.Key, out _)) throw new ArgumentException("Additional parameter duplicates a model property: " + parameter.Key); - _properties.Add(parameter.Key, new ExtractedObjProperty { Name = parameter.Key, Type = parameter.Value == null ? typeof(object) : parameter.Value.GetType(), OriginalValue = parameter.Value }); + _properties.Add(parameter.Key, new ExtractedObjProperty { Type = parameter.Value == null ? typeof(object) : parameter.Value.GetType(), OriginalValue = parameter.Value }); } } } @@ -87,7 +87,7 @@ public bool TryGetValue(string name, out ExtractedObjProperty property) return true; if (!Options.LazyPropertyAccess || !EngineTypeMetadataCache.TryGetPropertyAccessor(Type, name, out PropertyAccessor accessor)) return false; - property = new ExtractedObjProperty { Name = name, Type = accessor.PropertyType, OriginalValue = Model == null ? null : accessor.Getter(Model) }; + property = new ExtractedObjProperty { Type = accessor.PropertyType, OriginalValue = Model == null ? null : accessor.Getter(Model) }; _properties.Add(name, property); return true; } diff --git a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs index fc54993..c2a363e 100644 --- a/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs +++ b/ObjectSemantics.NET/Engine/EngineTemplateRenderer.cs @@ -69,7 +69,7 @@ private static void RenderNodes(TemplateNode[] nodes, RenderScope scope, EngineR } if (node.Kind == TemplateNodeKind.Value && scope.IsRow && node.Path.Text == ".") { - ExtractedObjProperty current = new ExtractedObjProperty { Name = ".", Type = scope.Type, OriginalValue = scope.Model }; + ExtractedObjProperty current = new ExtractedObjProperty { Type = scope.Type, OriginalValue = scope.Model }; context.Write(current.GetPropertyDisplayString(node.Format, context.Options)); continue; } diff --git a/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs b/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs index 0072504..6a19411 100644 --- a/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs +++ b/ObjectSemantics.NET/Engine/EngineTypeMetadataCache.cs @@ -27,7 +27,6 @@ public static Dictionary BuildPropertyMap(object v propertyMap.Add(accessor.Name, new ExtractedObjProperty { Type = accessor.PropertyType, - Name = accessor.Name, OriginalValue = value == null ? null : accessor.Getter(value) }); } @@ -39,7 +38,6 @@ public static Dictionary BuildPropertyMap(object v propertyMap.Add(p.Key, new ExtractedObjProperty { Type = p.Value != null ? p.Value.GetType() : typeof(object), - Name = p.Key, OriginalValue = p.Value }); } diff --git a/ObjectSemantics.NET/Engine/Extensions/StringBuilderExtensions.cs b/ObjectSemantics.NET/Engine/Extensions/StringBuilderExtensions.cs index 9d500d2..dc1cf03 100644 --- a/ObjectSemantics.NET/Engine/Extensions/StringBuilderExtensions.cs +++ b/ObjectSemantics.NET/Engine/Extensions/StringBuilderExtensions.cs @@ -1,7 +1,8 @@ -using System.Text; +using System.Text; namespace ObjectSemantics.NET.Engine.Extensions { + /// Legacy public helper retained for consumer compatibility; template rendering no longer uses string replacement. public static class StringBuilderExtensions { public static StringBuilder ReplaceFirstOccurrence(this StringBuilder sb, string search, string replace) diff --git a/ObjectSemantics.NET/Engine/Models/ExtractedObjProperty.cs b/ObjectSemantics.NET/Engine/Models/ExtractedObjProperty.cs index a17a943..5a64e1f 100644 --- a/ObjectSemantics.NET/Engine/Models/ExtractedObjProperty.cs +++ b/ObjectSemantics.NET/Engine/Models/ExtractedObjProperty.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Globalization; @@ -7,7 +7,6 @@ namespace ObjectSemantics.NET.Engine.Models internal class ExtractedObjProperty { public Type Type { get; set; } - public string Name { get; set; } public object OriginalValue { get; set; } public string StringFormatted => Convert.ToString(OriginalValue, CultureInfo.InvariantCulture); @@ -15,9 +14,5 @@ public bool IsEnumerableObject { get { return typeof(IEnumerable).IsAssignableFrom(Type) && Type != typeof(string); } } - public bool IsClassObject - { - get { return Type.IsClass && Type != typeof(string); } - } } -} \ No newline at end of file +} diff --git a/ObjectSemantics.NET/ObjectSemantics.NET.csproj b/ObjectSemantics.NET/ObjectSemantics.NET.csproj index cc1d799..c870f8d 100644 --- a/ObjectSemantics.NET/ObjectSemantics.NET.csproj +++ b/ObjectSemantics.NET/ObjectSemantics.NET.csproj @@ -6,7 +6,6 @@ Crudsoft Technologies @ 2025 https://github.com/swagfin/ObjectSemantics.NET icon.jpg - https://github.com/swagfin/ObjectSemantics.NET git Version 8.0.0 introduces structured template parsing and ordered rendering, reusable compiled templates, writer output, syntax diagnostics, strict mode, cancellation, and execution limits. @@ -17,7 +16,6 @@ Existing Map APIs and .NET Standard 2.0 support remain. Review README compatibil $(Version) $(Version) false - README.md ObjectMapper;Mapper;Formatter;ClassMapper;ClassToString;TemplateMapper; AutoMapper ObjectSemantics.NET @@ -42,10 +40,6 @@ Existing Map APIs and .NET Standard 2.0 support remain. Review README compatibil True \ - - True - \ -