From d4cbbd5656a243104f6439bf6b84b0f643812142 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 27 Feb 2025 09:45:45 +1000 Subject: [PATCH 1/2] Update to .NET 9 SDK, match Serilog targets, pull in Actions build --- .github/workflows/ci.yml | 41 +++++ Build.ps1 | 96 ++++++---- Directory.Build.props | 23 ++- Directory.Version.props | 5 + appveyor.yml | 21 --- example/Sample/Program.cs | 171 +++++++++--------- example/Sample/Sample.csproj | 5 +- global.json | 7 + serilog-expressions.sln | 3 +- .../Serilog.Expressions.csproj | 10 +- ...erilog.Expressions.PerformanceTests.csproj | 14 +- .../Serilog.Expressions.Tests.csproj | 11 +- 12 files changed, 234 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Directory.Version.props delete mode 100644 appveyor.yml create mode 100644 global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..acd3bc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# If this file is renamed, the incrementing run attempt number will be reset. + +name: CI + +on: + push: + branches: [ "dev", "main" ] + pull_request: + branches: [ "dev", "main" ] + +env: + CI_BUILD_NUMBER_BASE: ${{ github.run_number }} + CI_TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + +jobs: + build: + + # The build must run on Windows so that .NET Framework targets can be built and tested. + runs-on: windows-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Setup + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Compute build number + shell: bash + run: | + echo "CI_BUILD_NUMBER=$(($CI_BUILD_NUMBER_BASE+2300))" >> $GITHUB_ENV + - name: Build and Publish + env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + ./Build.ps1 diff --git a/Build.ps1 b/Build.ps1 index fd8264e..e798284 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,59 +1,79 @@ -echo "build: Build started" +Write-Output "build: Tool versions follow" + +dotnet --version +dotnet --list-sdks + +Write-Output "build: Build started" Push-Location $PSScriptRoot +try { + if(Test-Path .\artifacts) { + Write-Output "build: Cleaning ./artifacts" + Remove-Item ./artifacts -Force -Recurse + } -if(Test-Path .\artifacts) { - echo "build: Cleaning .\artifacts" - Remove-Item .\artifacts -Force -Recurse -} + & dotnet restore --no-cache + + $dbp = [Xml] (Get-Content .\Directory.Version.props) + $versionPrefix = $dbp.Project.PropertyGroup.VersionPrefix + + Write-Output "build: Package version prefix is $versionPrefix" + + $branch = @{ $true = $env:CI_TARGET_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:CI_TARGET_BRANCH]; + $revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:CI_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:CI_BUILD_NUMBER]; + $suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)) -replace '([^a-zA-Z0-9\-]*)', '')-$revision"}[$branch -eq "main" -and $revision -ne "local"] + $commitHash = $(git rev-parse --short HEAD) + $buildSuffix = @{ $true = "$($suffix)-$($commitHash)"; $false = "$($branch)-$($commitHash)" }[$suffix -ne ""] -& dotnet restore --no-cache + Write-Output "build: Package version suffix is $suffix" + Write-Output "build: Build version suffix is $buildSuffix" -$branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$env:APPVEYOR_REPO_BRANCH -ne $NULL]; -$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$env:APPVEYOR_BUILD_NUMBER -ne $NULL]; -$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "main" -and $revision -ne "local"] -$commitHash = $(git rev-parse --short HEAD) -$buildSuffix = @{ $true = "$($suffix)-$($commitHash)"; $false = "$($branch)-$($commitHash)" }[$suffix -ne ""] + & dotnet build -c Release --version-suffix=$buildSuffix /p:ContinuousIntegrationBuild=true + if($LASTEXITCODE -ne 0) { throw "Build failed" } -echo "build: Package version suffix is $suffix" -echo "build: Build version suffix is $buildSuffix" + foreach ($src in Get-ChildItem src/*) { + Push-Location $src -foreach ($src in ls src/*) { - Push-Location $src + Write-Output "build: Packaging project in $src" - echo "build: Packaging project in $src" + if ($suffix) { + & dotnet pack -c Release --no-build --no-restore -o ../../artifacts --version-suffix=$suffix + } else { + & dotnet pack -c Release --no-build --no-restore -o ../../artifacts + } + if($LASTEXITCODE -ne 0) { throw "Packaging failed" } - & dotnet build -c Release --version-suffix=$buildSuffix -p:EnableSourceLink=true - if ($suffix) { - & dotnet pack -c Release -o ..\..\artifacts --version-suffix=$suffix --no-build - } else { - & dotnet pack -c Release -o ..\..\artifacts --no-build + Pop-Location } - if($LASTEXITCODE -ne 0) { throw "build failed" } - Pop-Location -} + foreach ($test in Get-ChildItem test/*.Tests) { + Push-Location $test -foreach ($test in ls test/*.Tests) { - Push-Location $test + Write-Output "build: Testing project in $test" - echo "build: Testing project in $test" + & dotnet test -c Release --no-build --no-restore + if($LASTEXITCODE -ne 0) { throw "Testing failed" } - & dotnet test -c Release - if($LASTEXITCODE -ne 0) { throw "tests failed" } + Pop-Location + } - Pop-Location -} + if ($env:NUGET_API_KEY) { + # GitHub Actions will only supply this to branch builds and not PRs. We publish + # builds from any branch this action targets (i.e. main and dev). -foreach ($test in ls test/*.PerformanceTests) { - Push-Location $test + Write-Output "build: Publishing NuGet packages" - echo "build: Building project in $test" + foreach ($nupkg in Get-ChildItem artifacts/*.nupkg) { + & dotnet nuget push -k $env:NUGET_API_KEY -s https://api.nuget.org/v3/index.json "$nupkg" + if($LASTEXITCODE -ne 0) { throw "Publishing failed" } + } - & dotnet build -c Release - if($LASTEXITCODE -ne 0) { throw "performance test build failed" } + if (!($suffix)) { + Write-Output "build: Creating release for version $versionPrefix" + iex "gh release create v$versionPrefix --title v$versionPrefix --generate-notes $(get-item ./artifacts/*.nupkg) $(get-item ./artifacts/*.snupkg)" + } + } +} finally { Pop-Location } - -Pop-Location diff --git a/Directory.Build.props b/Directory.Build.props index 0957aaf..c114992 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,12 +1,25 @@ + + - enable latest - true - ../../assets/Serilog.snk - true - true + True + + true + $(MSBuildThisFileDirectory)assets/Serilog.snk false + enable enable + true + true + true + true + snupkg + + + + + diff --git a/Directory.Version.props b/Directory.Version.props new file mode 100644 index 0000000..8134cc7 --- /dev/null +++ b/Directory.Version.props @@ -0,0 +1,5 @@ + + + 5.1.0 + + diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 15d7eac..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: '{build}' -skip_tags: true -image: Visual Studio 2022 -build_script: -- pwsh: ./Build.ps1 -artifacts: -- path: artifacts/Serilog.*.nupkg -deploy: -- provider: NuGet - api_key: - secure: ZCEcKeB0btSRWVPgGPqQKphQeTcljBBsA4GKGW0Gmjw+UfXvS0LCcWzYdPXUWo5N - skip_symbols: true - on: - branch: /^(main|dev)$/ -- provider: GitHub - auth_token: - secure: p4LpVhBKxGS5WqucHxFQ5c7C8cP74kbNB0Z8k9Oxx/PMaDQ1+ibmoexNqVU5ZlmX - artifact: /Serilog.*\.nupkg/ - tag: v$(appveyor_build_version) - on: - branch: main diff --git a/example/Sample/Program.cs b/example/Sample/Program.cs index b26266f..4c76010 100644 --- a/example/Sample/Program.cs +++ b/example/Sample/Program.cs @@ -3,108 +3,101 @@ using Serilog.Templates; using Serilog.Templates.Themes; -namespace Sample; +SelfLog.Enable(Console.Error); -// ReSharper disable once ClassNeverInstantiated.Global -public class Program -{ - public static void Main() - { - SelfLog.Enable(Console.Error); +TextFormattingExample1(); +JsonFormattingExample(); +PipelineComponentExample(); +TextFormattingExample2(); - TextFormattingExample1(); - JsonFormattingExample(); - PipelineComponentExample(); - TextFormattingExample2(); - } +return; - static void TextFormattingExample1() - { - using var log = new LoggerConfiguration() - .Enrich.WithProperty("Application", "Sample") - .WriteTo.Console(new ExpressionTemplate( - "[{@t:HH:mm:ss} {@l:u3}" + - "{#if SourceContext is not null} ({Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1)}){#end}] " + - "{@m} (first item is {coalesce(Items[0], '')}) {rest()}\n{@x}", - theme: TemplateTheme.Code)) - .CreateLogger(); +static void TextFormattingExample1() +{ + using var log = new LoggerConfiguration() + .Enrich.WithProperty("Application", "Sample") + .WriteTo.Console(new ExpressionTemplate( + "[{@t:HH:mm:ss} {@l:u3}" + + "{#if SourceContext is not null} ({Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1)}){#end}] " + + "{@m} (first item is {coalesce(Items[0], '')}) {rest()}\n{@x}", + theme: TemplateTheme.Code)) + .CreateLogger(); - log.Information("Running {Example}", nameof(TextFormattingExample1)); + log.Information("Running {Example}", nameof(TextFormattingExample1)); - log.ForContext() - .Information("Cart contains {@Items}", new[] { "Tea", "Coffee" }); + log.ForContext() + .Information("Cart contains {@Items}", new[] { "Tea", "Coffee" }); - log.ForContext() - .Information("Cart contains {@Items}", new[] { "Apricots" }); - } + log.ForContext() + .Information("Cart contains {@Items}", new[] { "Apricots" }); +} - static void JsonFormattingExample() - { - using var log = new LoggerConfiguration() - .Enrich.WithProperty("Application", "Example") - .WriteTo.Console(new ExpressionTemplate( - "{ {@t: UtcDateTime(@t), @mt, @l: if @l = 'Information' then undefined() else @l, @x, ..@p} }\n")) - .CreateLogger(); +static void JsonFormattingExample() +{ + using var log = new LoggerConfiguration() + .Enrich.WithProperty("Application", "Example") + .WriteTo.Console(new ExpressionTemplate( + "{ {@t: UtcDateTime(@t), @mt, @l: if @l = 'Information' then undefined() else @l, @x, ..@p} }\n")) + .CreateLogger(); - log.Information("Running {Example}", nameof(JsonFormattingExample)); + log.Information("Running {Example}", nameof(JsonFormattingExample)); - log.ForContext() - .Information("Cart contains {@Items}", new[] { "Tea", "Coffee" }); + log.ForContext() + .Information("Cart contains {@Items}", new[] { "Tea", "Coffee" }); - log.ForContext() - .Warning("Cart is empty"); - } + log.ForContext() + .Warning("Cart is empty"); +} - static void PipelineComponentExample() - { - using var log = new LoggerConfiguration() - .Enrich.WithProperty("Application", "Example") - .Enrich.WithComputed("FirstItem", "coalesce(Items[0], '')") - .Enrich.WithComputed("SourceContext", "coalesce(Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), '')") - .Filter.ByIncludingOnly("Items is null or Items[?] like 'C%'") - .WriteTo.Console(outputTemplate: - "[{Timestamp:HH:mm:ss} {Level:u3} ({SourceContext})] {Message:lj} (first item is {FirstItem}){NewLine}{Exception}") - .CreateLogger(); +static void PipelineComponentExample() +{ + using var log = new LoggerConfiguration() + .Enrich.WithProperty("Application", "Example") + .Enrich.WithComputed("FirstItem", "coalesce(Items[0], '')") + .Enrich.WithComputed("SourceContext", "coalesce(Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), '')") + .Filter.ByIncludingOnly("Items is null or Items[?] like 'C%'") + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3} ({SourceContext})] {Message:lj} (first item is {FirstItem}){NewLine}{Exception}") + .CreateLogger(); - log.Information("Running {Example}", nameof(PipelineComponentExample)); + log.Information("Running {Example}", nameof(PipelineComponentExample)); - log.ForContext() - .Information("Cart contains {@Items}", new[] { "Tea", "Coffee" }); + log.ForContext() + .Information("Cart contains {@Items}", new[] { "Tea", "Coffee" }); - log.ForContext() - .Information("Cart contains {@Items}", new[] { "Apricots" }); - } + log.ForContext() + .Information("Cart contains {@Items}", new[] { "Apricots" }); +} + +static void TextFormattingExample2() +{ + // Emulates `Microsoft.Extensions.Logging`'s `ConsoleLogger`. - static void TextFormattingExample2() + var melon = new TemplateTheme(TemplateTheme.Literate, new Dictionary { - // Emulates `Microsoft.Extensions.Logging`'s `ConsoleLogger`. - - var melon = new TemplateTheme(TemplateTheme.Literate, new Dictionary - { - // `Information` is dark green in MEL. - [TemplateThemeStyle.LevelInformation] = "\x1b[38;5;34m", - [TemplateThemeStyle.String] = "\x1b[38;5;159m", - [TemplateThemeStyle.Number] = "\x1b[38;5;159m" - }); - - using var log = new LoggerConfiguration() - .WriteTo.Console(new ExpressionTemplate( - "{@l:w4}: {SourceContext}\n" + - "{#if Scope is not null}" + - " {#each s in Scope}=> {s}{#delimit} {#end}\n" + - "{#end}" + - " {@m}\n" + - "{@x}", - theme: melon)) - .CreateLogger(); - - var program = log.ForContext(); - program.Information("Host listening at {ListenUri}", "https://hello-world.local"); - - program - .ForContext("Scope", new[] {"Main", "TextFormattingExample2()"}) - .Information("HTTP {Method} {Path} responded {StatusCode} in {Elapsed:0.000} ms", "GET", "/api/hello", 200, 1.23); - - program.Warning("We've reached the end of the line"); - } -} \ No newline at end of file + // `Information` is dark green in MEL. + [TemplateThemeStyle.LevelInformation] = "\x1b[38;5;34m", + [TemplateThemeStyle.String] = "\x1b[38;5;159m", + [TemplateThemeStyle.Number] = "\x1b[38;5;159m" + }); + + using var log = new LoggerConfiguration() + .WriteTo.Console(new ExpressionTemplate( + "{@l:w4}: {SourceContext}\n" + + "{#if Scope is not null}" + + " {#each s in Scope}=> {s}{#delimit} {#end}\n" + + "{#end}" + + " {@m}\n" + + "{@x}", + theme: melon)) + .CreateLogger(); + + var program = log.ForContext(); + program.Information("Host listening at {ListenUri}", "https://hello-world.local"); + + program + .ForContext("Scope", new[] {"Main", "TextFormattingExample2()"}) + .Information("HTTP {Method} {Path} responded {StatusCode} in {Elapsed:0.000} ms", "GET", "/api/hello", 200, 1.23); + + program.Warning("We've reached the end of the line"); +} diff --git a/example/Sample/Sample.csproj b/example/Sample/Sample.csproj index 4614021..bb2347d 100644 --- a/example/Sample/Sample.csproj +++ b/example/Sample/Sample.csproj @@ -1,12 +1,13 @@  - net6.0 + net9.0 Exe + false - + diff --git a/global.json b/global.json new file mode 100644 index 0000000..ed7ea04 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.200", + "allowPrerelease": false, + "rollForward": "latestFeature" + } +} diff --git a/serilog-expressions.sln b/serilog-expressions.sln index 4ebf0cd..45dc2e8 100644 --- a/serilog-expressions.sln +++ b/serilog-expressions.sln @@ -9,12 +9,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "global", "global", "{24B112 ProjectSection(SolutionItems) = preProject .gitattributes = .gitattributes .gitignore = .gitignore - appveyor.yml = appveyor.yml Build.ps1 = Build.ps1 Directory.Build.props = Directory.Build.props LICENSE = LICENSE README.md = README.md RunPerfTests.ps1 = RunPerfTests.ps1 + Directory.Version.props = Directory.Version.props + global.json = global.json EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "example", "example", "{BD94A77E-34B1-478E-B921-E87A5F71B574}" diff --git a/src/Serilog.Expressions/Serilog.Expressions.csproj b/src/Serilog.Expressions/Serilog.Expressions.csproj index 12355d1..eaa9f2a 100644 --- a/src/Serilog.Expressions/Serilog.Expressions.csproj +++ b/src/Serilog.Expressions/Serilog.Expressions.csproj @@ -3,22 +3,18 @@ An embeddable mini-language for filtering, enriching, and formatting Serilog events, ideal for use with JSON or XML configuration. - 5.1.0 Serilog Contributors net471;net462 - $(TargetFrameworks);net8.0;net6.0;netstandard2.0 - true - Serilog + $(TargetFrameworks);net9.0;net8.0;net6.0;netstandard2.0 serilog https://github.com/serilog/serilog-expressions icon.png Apache-2.0 - https://github.com/serilog/serilog-expressions - git + Serilog README.md @@ -35,7 +31,7 @@ - + diff --git a/test/Serilog.Expressions.PerformanceTests/Serilog.Expressions.PerformanceTests.csproj b/test/Serilog.Expressions.PerformanceTests/Serilog.Expressions.PerformanceTests.csproj index d5904d7..f72a4d5 100644 --- a/test/Serilog.Expressions.PerformanceTests/Serilog.Expressions.PerformanceTests.csproj +++ b/test/Serilog.Expressions.PerformanceTests/Serilog.Expressions.PerformanceTests.csproj @@ -1,15 +1,19 @@ - net6.0 + net9.0 true + false - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj b/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj index 6c6d887..517eb60 100644 --- a/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj +++ b/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj @@ -1,16 +1,17 @@  - net6.0 + net9.0 true + false - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + From a759b929ee7b8ac38e114b90e22ad4eadf75bccf Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 27 Feb 2025 11:22:34 +1000 Subject: [PATCH 2/2] Add the Nest() function, for processing dotted property names into nested objects --- README.md | 3 +- .../Expressions/Operators.cs | 1 + .../Expressions/Runtime/RuntimeOperators.cs | 17 +++ .../Support/UnflattenDottedPropertyNames.cs | 126 ++++++++++++++++++ .../Cases/expression-evaluation-cases.asv | 4 + 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/Serilog.Expressions/Expressions/Runtime/Support/UnflattenDottedPropertyNames.cs diff --git a/README.md b/README.md index 346a41f..e3454ee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# _Serilog Expressions_ [![Build status](https://ci.appveyor.com/api/projects/status/vmcskdk2wjn1rpps/branch/dev?svg=true)](https://ci.appveyor.com/project/serilog/serilog-expressions/branch/dev) [![NuGet Package](https://img.shields.io/nuget/vpre/serilog.expressions)](https://nuget.org/packages/serilog.expressions) +# Serilog.Expressions [![Build status](https://github.com/serilog/serilog-expressions/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/serilog/serilog-expressions/actions) [![NuGet Package](https://img.shields.io/nuget/vpre/serilog.expressions)](https://nuget.org/packages/serilog.expressions) An embeddable mini-language for filtering, enriching, and formatting Serilog events, ideal for use with JSON or XML configuration. @@ -201,6 +201,7 @@ calling a function will be undefined if: | `IsDefined(x)` | Returns `true` if the expression `x` has a value, including `null`, or `false` if `x` is undefined. | | `LastIndexOf(s, p)` | Returns the last index of substring `p` in string `s`, or -1 if the substring does not appear. | | `Length(x)` | Returns the length of a string or array. | +| `Nest(o)` | Converts dotted (flattened) property names of object `o` into nested sub-objects. | | `Now()` | Returns `DateTimeOffset.Now`. | | `Replace(s, p, r)` | Replace occurrences of substring `p` in string `s` with replacement `r`. | | `Rest([deep])` | In an `ExpressionTemplate`, returns an object containing the first-class event properties not otherwise referenced in the template. If `deep` is `true`, also excludes properties referenced in the event's message template. | diff --git a/src/Serilog.Expressions/Expressions/Operators.cs b/src/Serilog.Expressions/Expressions/Operators.cs index 09f67d2..109596e 100644 --- a/src/Serilog.Expressions/Expressions/Operators.cs +++ b/src/Serilog.Expressions/Expressions/Operators.cs @@ -38,6 +38,7 @@ static class Operators public const string OpIsDefined = "IsDefined"; public const string OpLastIndexOf = "LastIndexOf"; public const string OpLength = "Length"; + public const string OpNest = "Nest"; public const string OpNow = "Now"; public const string OpReplace = "Replace"; public const string OpRound = "Round"; diff --git a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs index 738c766..6a9347a 100644 --- a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs +++ b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs @@ -16,6 +16,7 @@ using Serilog.Debugging; using Serilog.Events; using Serilog.Expressions.Compilation.Linq; +using Serilog.Expressions.Runtime.Support; using Serilog.Templates.Rendering; // ReSharper disable ForCanBeConvertedToForeach, InvertIf, MemberCanBePrivate.Global, UnusedMember.Global, InconsistentNaming, ReturnTypeCanBeNotNullable @@ -589,4 +590,20 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v return new StructureValue(result); } + + public static LogEventPropertyValue? Nest(LogEventPropertyValue? maybeStructure) + { + if (maybeStructure is not StructureValue { Properties: { } flat }) + return null; + + var byName = new Dictionary(flat.Count); + foreach (var property in flat) + { + // Supports duplicate property names, despite these being hard to generate. + byName[property.Name] = property.Value; + } + + var props = UnflattenDottedPropertyNames.ProcessDottedPropertyNames(byName); + return new StructureValue(props.Select(p => new LogEventProperty(p.Key, p.Value)).ToList()); + } } diff --git a/src/Serilog.Expressions/Expressions/Runtime/Support/UnflattenDottedPropertyNames.cs b/src/Serilog.Expressions/Expressions/Runtime/Support/UnflattenDottedPropertyNames.cs new file mode 100644 index 0000000..c0567c1 --- /dev/null +++ b/src/Serilog.Expressions/Expressions/Runtime/Support/UnflattenDottedPropertyNames.cs @@ -0,0 +1,126 @@ +// Copyright © Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics; +using Serilog.Events; + +namespace Serilog.Expressions.Runtime.Support; + +/// +/// Nest (un-flatten) properties with dotted names. A property with name "a.b" will be transmitted to Seq as +/// a structure with name "a", and one member "b". +/// +/// Forked from . +static class UnflattenDottedPropertyNames +{ + const int MaxDepth = 10; + + public static IReadOnlyDictionary ProcessDottedPropertyNames(IReadOnlyDictionary maybeDotted) + { + return DottedToNestedRecursive(maybeDotted, 0); + } + + static IReadOnlyDictionary DottedToNestedRecursive(IReadOnlyDictionary maybeDotted, int depth) + { + if (depth == MaxDepth) + return maybeDotted; + + // Assume that the majority of entries will be bare or have unique prefixes. + var result = new Dictionary(maybeDotted.Count); + + // Sorted for determinism. + var dotted = new SortedDictionary(StringComparer.Ordinal); + + // First - give priority to bare names, since these would otherwise be claimed by the parents of further nested + // layers and we'd have nowhere to put them when resolving conflicts. (Dotted entries that conflict can keep their dotted keys). + + foreach (var kv in maybeDotted) + { + if (IsDottedIdentifier(kv.Key)) + { + // Stash for processing in the next stage. + dotted.Add(kv.Key, kv.Value); + } + else + { + result.Add(kv.Key, kv.Value); + } + } + + // Then - for dotted keys with a prefix not already present in the result, convert to structured data and add to + // the result. Any set of dotted names that collide with a preexisting key will be left as-is. + + string? prefix = null; + Dictionary? nested = null; + foreach (var kv in dotted) + { + var (newPrefix, rem) = TakeFirstIdentifier(kv.Key); + + if (prefix != null && prefix != newPrefix) + { + result.Add(prefix, MakeStructureValue(DottedToNestedRecursive(nested!, depth + 1))); + prefix = null; + nested = null; + } + + if (nested != null && !nested.ContainsKey(rem)) + { + prefix = newPrefix; + nested.Add(rem, kv.Value); + } + else if (nested == null && !result.ContainsKey(newPrefix)) + { + prefix = newPrefix; + nested = new () { { rem, kv.Value } }; + } + else + { + result.Add(kv.Key, kv.Value); + } + } + + if (prefix != null) + { + result[prefix] = MakeStructureValue(DottedToNestedRecursive(nested!, depth + 1)); + } + + return result; + } + + static StructureValue MakeStructureValue(IReadOnlyDictionary properties) + { + return new StructureValue(properties.Select(kv => new LogEventProperty(kv.Key, kv.Value)), typeTag: null); + } + + static bool IsDottedIdentifier(string key) => + key.Contains('.') && + !key.StartsWith(".", StringComparison.Ordinal) && + !key.EndsWith(".", StringComparison.Ordinal) && + key.Split('.').All(IsIdentifier); + + static bool IsIdentifier(string s) => s.Length != 0 && + !char.IsDigit(s[0]) && + s.All(ch => char.IsLetter(ch) || char.IsDigit(ch) || ch == '_'); + + static (string, string) TakeFirstIdentifier(string dottedIdentifier) + { + // We can do this simplistically because keys in `dotted` conform to `IsDottedName`. + Debug.Assert(IsDottedIdentifier(dottedIdentifier)); + + var firstDot = dottedIdentifier.IndexOf('.'); + var prefix = dottedIdentifier.Substring(0, firstDot); + var rem = dottedIdentifier.Substring(firstDot + 1); + return (prefix, rem); + } +} diff --git a/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv b/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv index 0b5f0c9..33142b7 100644 --- a/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv +++ b/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv @@ -324,3 +324,7 @@ replace('mess', 'ss', 'an') ⇶ 'mean' replace('mess', 's', 'an') ⇶ 'meanan' replace('xyz', 'x', '$0') ⇶ '$0yz' replace('xyz', 'x', concat('$', '0')) ⇶ '$0yz' + +// Nest +nest({'a.b': 1, 'a.c': 2, 'a.c.d': 3}) ⇶ {a: {b: 1, c: 2, 'c.d': 3}} +nest('a.b.c') ⇶ undefined()