Skip to content

Commit a759b92

Browse files
committed
Add the Nest() function, for processing dotted property names into nested objects
1 parent d4cbbd5 commit a759b92

File tree

5 files changed

+150
-1
lines changed

5 files changed

+150
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# _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)
1+
# 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)
22

33
An embeddable mini-language for filtering, enriching, and formatting Serilog
44
events, ideal for use with JSON or XML configuration.
@@ -201,6 +201,7 @@ calling a function will be undefined if:
201201
| `IsDefined(x)` | Returns `true` if the expression `x` has a value, including `null`, or `false` if `x` is undefined. |
202202
| `LastIndexOf(s, p)` | Returns the last index of substring `p` in string `s`, or -1 if the substring does not appear. |
203203
| `Length(x)` | Returns the length of a string or array. |
204+
| `Nest(o)` | Converts dotted (flattened) property names of object `o` into nested sub-objects. |
204205
| `Now()` | Returns `DateTimeOffset.Now`. |
205206
| `Replace(s, p, r)` | Replace occurrences of substring `p` in string `s` with replacement `r`. |
206207
| `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. |

src/Serilog.Expressions/Expressions/Operators.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ static class Operators
3838
public const string OpIsDefined = "IsDefined";
3939
public const string OpLastIndexOf = "LastIndexOf";
4040
public const string OpLength = "Length";
41+
public const string OpNest = "Nest";
4142
public const string OpNow = "Now";
4243
public const string OpReplace = "Replace";
4344
public const string OpRound = "Round";

src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Serilog.Debugging;
1717
using Serilog.Events;
1818
using Serilog.Expressions.Compilation.Linq;
19+
using Serilog.Expressions.Runtime.Support;
1920
using Serilog.Templates.Rendering;
2021

2122
// ReSharper disable ForCanBeConvertedToForeach, InvertIf, MemberCanBePrivate.Global, UnusedMember.Global, InconsistentNaming, ReturnTypeCanBeNotNullable
@@ -589,4 +590,20 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v
589590

590591
return new StructureValue(result);
591592
}
593+
594+
public static LogEventPropertyValue? Nest(LogEventPropertyValue? maybeStructure)
595+
{
596+
if (maybeStructure is not StructureValue { Properties: { } flat })
597+
return null;
598+
599+
var byName = new Dictionary<string, LogEventPropertyValue>(flat.Count);
600+
foreach (var property in flat)
601+
{
602+
// Supports duplicate property names, despite these being hard to generate.
603+
byName[property.Name] = property.Value;
604+
}
605+
606+
var props = UnflattenDottedPropertyNames.ProcessDottedPropertyNames(byName);
607+
return new StructureValue(props.Select(p => new LogEventProperty(p.Key, p.Value)).ToList());
608+
}
592609
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright © Serilog Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Diagnostics;
16+
using Serilog.Events;
17+
18+
namespace Serilog.Expressions.Runtime.Support;
19+
20+
/// <summary>
21+
/// Nest (un-flatten) properties with dotted names. A property with name <c>"a.b"</c> will be transmitted to Seq as
22+
/// a structure with name <c>"a"</c>, and one member <c>"b"</c>.
23+
/// </summary>
24+
/// <remarks>Forked from <a href="https://github.com/datalust/serilog-sinks-seq/blob/6815d7307b477747e05e782f7dfbcff8c8dd20a2/src/Serilog.Sinks.Seq/Sinks/Seq/Conventions/UnflattenDottedPropertyNames.cs"/>.</remarks>
25+
static class UnflattenDottedPropertyNames
26+
{
27+
const int MaxDepth = 10;
28+
29+
public static IReadOnlyDictionary<string, LogEventPropertyValue> ProcessDottedPropertyNames(IReadOnlyDictionary<string, LogEventPropertyValue> maybeDotted)
30+
{
31+
return DottedToNestedRecursive(maybeDotted, 0);
32+
}
33+
34+
static IReadOnlyDictionary<string, LogEventPropertyValue> DottedToNestedRecursive(IReadOnlyDictionary<string, LogEventPropertyValue> maybeDotted, int depth)
35+
{
36+
if (depth == MaxDepth)
37+
return maybeDotted;
38+
39+
// Assume that the majority of entries will be bare or have unique prefixes.
40+
var result = new Dictionary<string, LogEventPropertyValue>(maybeDotted.Count);
41+
42+
// Sorted for determinism.
43+
var dotted = new SortedDictionary<string, LogEventPropertyValue>(StringComparer.Ordinal);
44+
45+
// First - give priority to bare names, since these would otherwise be claimed by the parents of further nested
46+
// layers and we'd have nowhere to put them when resolving conflicts. (Dotted entries that conflict can keep their dotted keys).
47+
48+
foreach (var kv in maybeDotted)
49+
{
50+
if (IsDottedIdentifier(kv.Key))
51+
{
52+
// Stash for processing in the next stage.
53+
dotted.Add(kv.Key, kv.Value);
54+
}
55+
else
56+
{
57+
result.Add(kv.Key, kv.Value);
58+
}
59+
}
60+
61+
// Then - for dotted keys with a prefix not already present in the result, convert to structured data and add to
62+
// the result. Any set of dotted names that collide with a preexisting key will be left as-is.
63+
64+
string? prefix = null;
65+
Dictionary<string, LogEventPropertyValue>? nested = null;
66+
foreach (var kv in dotted)
67+
{
68+
var (newPrefix, rem) = TakeFirstIdentifier(kv.Key);
69+
70+
if (prefix != null && prefix != newPrefix)
71+
{
72+
result.Add(prefix, MakeStructureValue(DottedToNestedRecursive(nested!, depth + 1)));
73+
prefix = null;
74+
nested = null;
75+
}
76+
77+
if (nested != null && !nested.ContainsKey(rem))
78+
{
79+
prefix = newPrefix;
80+
nested.Add(rem, kv.Value);
81+
}
82+
else if (nested == null && !result.ContainsKey(newPrefix))
83+
{
84+
prefix = newPrefix;
85+
nested = new () { { rem, kv.Value } };
86+
}
87+
else
88+
{
89+
result.Add(kv.Key, kv.Value);
90+
}
91+
}
92+
93+
if (prefix != null)
94+
{
95+
result[prefix] = MakeStructureValue(DottedToNestedRecursive(nested!, depth + 1));
96+
}
97+
98+
return result;
99+
}
100+
101+
static StructureValue MakeStructureValue(IReadOnlyDictionary<string,LogEventPropertyValue> properties)
102+
{
103+
return new StructureValue(properties.Select(kv => new LogEventProperty(kv.Key, kv.Value)), typeTag: null);
104+
}
105+
106+
static bool IsDottedIdentifier(string key) =>
107+
key.Contains('.') &&
108+
!key.StartsWith(".", StringComparison.Ordinal) &&
109+
!key.EndsWith(".", StringComparison.Ordinal) &&
110+
key.Split('.').All(IsIdentifier);
111+
112+
static bool IsIdentifier(string s) => s.Length != 0 &&
113+
!char.IsDigit(s[0]) &&
114+
s.All(ch => char.IsLetter(ch) || char.IsDigit(ch) || ch == '_');
115+
116+
static (string, string) TakeFirstIdentifier(string dottedIdentifier)
117+
{
118+
// We can do this simplistically because keys in `dotted` conform to `IsDottedName`.
119+
Debug.Assert(IsDottedIdentifier(dottedIdentifier));
120+
121+
var firstDot = dottedIdentifier.IndexOf('.');
122+
var prefix = dottedIdentifier.Substring(0, firstDot);
123+
var rem = dottedIdentifier.Substring(firstDot + 1);
124+
return (prefix, rem);
125+
}
126+
}

test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,7 @@ replace('mess', 'ss', 'an') ⇶ 'mean'
324324
replace('mess', 's', 'an') ⇶ 'meanan'
325325
replace('xyz', 'x', '$0') ⇶ '$0yz'
326326
replace('xyz', 'x', concat('$', '0')) ⇶ '$0yz'
327+
328+
// Nest
329+
nest({'a.b': 1, 'a.c': 2, 'a.c.d': 3}) ⇶ {a: {b: 1, c: 2, 'c.d': 3}}
330+
nest('a.b.c') ⇶ undefined()

0 commit comments

Comments
 (0)