diff --git a/Build.ps1 b/Build.ps1 index 8283851..1ea9c5e 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -11,7 +11,7 @@ if(Test-Path .\artifacts) { $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 "master" -and $revision -ne "local"] +$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 ""] @@ -57,4 +57,4 @@ foreach ($test in ls test/*.PerformanceTests) { Pop-Location } -Pop-Location \ No newline at end of file +Pop-Location diff --git a/README.md b/README.md index 683e821..2d497f7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ events, ideal for use with JSON or XML configuration. Install the package from NuGet: ```shell -dotnet add package Serilog.Expressions -v 1.0.0-* +dotnet add package Serilog.Expressions ``` The package adds extension methods to Serilog's `Filter`, `WriteTo`, and @@ -85,6 +85,8 @@ _Serilog.Expressions_ includes the `ExpressionTemplate` class for text formattin it works with any text-based Serilog sink: ```csharp +// using Serilog.Templates; + Log.Logger = new LoggerConfiguration() .WriteTo.Console(new ExpressionTemplate( "[{@t:HH:mm:ss} {@l:u3} ({SourceContext})] {@m} (first item is {Items[0]})\n{@x}")) @@ -93,27 +95,31 @@ Log.Logger = new LoggerConfiguration() Note the use of `{Items[0]}`: "holes" in expression templates can include any valid expression. -Newline-delimited JSON (for example, emulating the [CLEF format](https://github.com/serilog/serilog-formatting-compact)) can be generated +Newline-delimited JSON (for example, replicating the [CLEF format](https://github.com/serilog/serilog-formatting-compact)) can be generated using object literals: ```csharp .WriteTo.Console(new ExpressionTemplate( - "{ {@t, @mt, @l: if @l = 'Information' then undefined() else @l, @x, ..@p} }\n")) + "{ {@t, @mt, @r, @l: if @l = 'Information' then undefined() else @l, @x, ..@p} }\n")) ``` ## Language reference -### Built-in properties +### Properties The following properties are available in expressions: - * All first-class properties of the event; no special syntax: `SourceContext` and `Items` are used in the formatting example above + * **All first-class properties of the event** — no special syntax: `SourceContext` and `Items` are used in the formatting example above * `@t` - the event's timestamp, as a `DateTimeOffset` * `@m` - the rendered message * `@mt` - the raw message template * `@l` - the event's level, as a `LogEventLevel` * `@x` - the exception associated with the event, if any, as an `Exception` * `@p` - a dictionary containing all first-class properties; this supports properties with non-identifier names, for example `@p['snake-case-name']` + * `@i` - event id; a 32-bit numeric hash of the event's message template + * `@r` - renderings; if any tokens in the message template include .NET-specific formatting, an array of rendered values for each such token + +The built-in properties mirror those available in the CLEF format. ### Literals @@ -140,7 +146,8 @@ A typical set of operators is supported: * Existence `is null` and `is not null` * SQL-style `like` and `not like`, with `%` and `_` wildcards (double wildcards to escape them) * Array membership with `in` and `not in` - * Indexers `a[b]` and accessors `a.b` + * Accessors `a.b` + * Indexers `a['b']` and `a[0]` * Wildcard indexing - `a[?]` any, and `a[*]` all * Conditional `if a then b else c` (all branches required) @@ -172,12 +179,15 @@ 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, t)` | Returns the last index of substring `t` in string `s`, or -1 if the substring does not appear. | | `Length(x)` | Returns the length of a string or array. | +| `Now()` | Returns `DateTimeOffset.Now`. | | `Round(n, m)` | Round the number `n` to `m` decimal places. | | `StartsWith(s, t)` | Tests whether the string `s` starts with substring `t`. | | `Substring(s, start, [length])` | Return the substring of string `s` from `start` to the end of the string, or of `length` characters, if this argument is supplied. | | `TagOf(o)` | Returns the `TypeTag` field of a captured object (i.e. where `TypeOf(x)` is `'object'`). | +| `ToString(x, f)` | Applies the format string `f` to the formattable value `x`. | | `TypeOf(x)` | Returns a string describing the type of expression `x`: a .NET type name if `x` is scalar and non-null, or, `'array'`, `'object'`, `'dictionary'`, `'null'`, or `'undefined'`. | | `Undefined()` | Explicitly mark an undefined value. | +| `UtcDateTime(x)` | Convert a `DateTime` or `DateTimeOffset` into a UTC `DateTime`. | Functions that compare text accept an optional postfix `ci` modifier to select case-insensitive comparisons: diff --git a/src/Serilog.Expressions/Expressions/Ast/Expression.cs b/src/Serilog.Expressions/Expressions/Ast/Expression.cs index b67abcd..045d1dd 100644 --- a/src/Serilog.Expressions/Expressions/Ast/Expression.cs +++ b/src/Serilog.Expressions/Expressions/Ast/Expression.cs @@ -2,5 +2,7 @@ { abstract class Expression { + // Used only as an enabler for testing and debugging. + public abstract override string ToString(); } } diff --git a/src/Serilog.Expressions/Expressions/Ast/IndexOfMatchExpression.cs b/src/Serilog.Expressions/Expressions/Ast/IndexOfMatchExpression.cs index 2de6262..574ae0a 100644 --- a/src/Serilog.Expressions/Expressions/Ast/IndexOfMatchExpression.cs +++ b/src/Serilog.Expressions/Expressions/Ast/IndexOfMatchExpression.cs @@ -13,5 +13,10 @@ public IndexOfMatchExpression(Expression corpus, Regex regex) Corpus = corpus ?? throw new ArgumentNullException(nameof(corpus)); Regex = regex ?? throw new ArgumentNullException(nameof(regex)); } + + public override string ToString() + { + return $"_Internal_IndexOfMatch({Corpus}, '{Regex.ToString().Replace("'", "''")}')"; + } } } diff --git a/src/Serilog.Expressions/Expressions/BuiltInProperty.cs b/src/Serilog.Expressions/Expressions/BuiltInProperty.cs index e499dda..cf1a563 100644 --- a/src/Serilog.Expressions/Expressions/BuiltInProperty.cs +++ b/src/Serilog.Expressions/Expressions/BuiltInProperty.cs @@ -1,5 +1,20 @@ +// 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. + namespace Serilog.Expressions { + // See https://github.com/serilog/serilog-formatting-compact#reified-properties static class BuiltInProperty { public const string Exception = "x"; @@ -8,5 +23,7 @@ static class BuiltInProperty public const string Message = "m"; public const string MessageTemplate = "mt"; public const string Properties = "p"; + public const string Renderings = "r"; + public const string EventId = "i"; } } \ No newline at end of file diff --git a/src/Serilog.Expressions/Expressions/Compilation/ExpressionCompiler.cs b/src/Serilog.Expressions/Expressions/Compilation/ExpressionCompiler.cs index 7a1a4b5..01639e4 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/ExpressionCompiler.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/ExpressionCompiler.cs @@ -10,7 +10,7 @@ namespace Serilog.Expressions.Compilation { static class ExpressionCompiler { - public static CompiledExpression Compile(Expression expression, NameResolver nameResolver) + public static Expression Translate(Expression expression) { var actual = expression; actual = VariadicCallRewriter.Rewrite(actual); @@ -19,7 +19,12 @@ public static CompiledExpression Compile(Expression expression, NameResolver nam actual = PropertiesObjectAccessorTransformer.Rewrite(actual); actual = ConstantArrayEvaluator.Evaluate(actual); actual = WildcardComprehensionTransformer.Expand(actual); - + return actual; + } + + public static CompiledExpression Compile(Expression expression, NameResolver nameResolver) + { + var actual = Translate(expression); return LinqExpressionCompiler.Compile(actual, nameResolver); } } diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/EventIdHash.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/EventIdHash.cs new file mode 100644 index 0000000..087a86f --- /dev/null +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/EventIdHash.cs @@ -0,0 +1,55 @@ +// 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; + +// ReSharper disable ForCanBeConvertedToForeach + +namespace Serilog.Expressions.Compilation.Linq +{ + /// + /// Hash functions for message templates. See . + /// + public static class EventIdHash + { + /// + /// Compute a 32-bit hash of the provided . The + /// resulting hash value can be uses as an event id in lieu of transmitting the + /// full template string. + /// + /// A message template. + /// A 32-bit hash of the template. + [CLSCompliant(false)] + public static uint Compute(string messageTemplate) + { + if (messageTemplate == null) throw new ArgumentNullException(nameof(messageTemplate)); + + // Jenkins one-at-a-time https://en.wikipedia.org/wiki/Jenkins_hash_function + unchecked + { + uint hash = 0; + for (var i = 0; i < messageTemplate.Length; ++i) + { + hash += messageTemplate[i]; + hash += hash << 10; + hash ^= hash >> 6; + } + hash += hash << 3; + hash ^= hash >> 11; + hash += hash << 15; + return hash; + } + } + } +} diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs index 5892758..ff4b3a5 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using Serilog.Events; using Serilog.Formatting.Display; +using Serilog.Parsing; // ReSharper disable ParameterTypeCanBeEnumerable.Global @@ -15,6 +16,8 @@ static class Intrinsics { static readonly LogEventPropertyValue NegativeOne = new ScalarValue(-1); static readonly LogEventPropertyValue Tombstone = new ScalarValue("😬 (if you see this you have found a bug.)"); + + // TODO #19: formatting is culture-specific. static readonly MessageTemplateTextFormatter MessageFormatter = new MessageTemplateTextFormatter("{Message:lj}"); public static List CollectSequenceElements(LogEventPropertyValue?[] elements) @@ -159,5 +162,25 @@ public static string RenderMessage(LogEvent logEvent) MessageFormatter.Format(logEvent, sw); return sw.ToString(); } + + public static LogEventPropertyValue? GetRenderings(LogEvent logEvent) + { + List? elements = null; + foreach (var token in logEvent.MessageTemplate.Tokens) + { + if (token is PropertyToken pt && pt.Format != null) + { + elements ??= new List(); + + var space = new StringWriter(); + + // TODO #19: formatting is culture-specific. + pt.Render(logEvent.Properties, space); + elements.Add(new ScalarValue(space.ToString())); + } + } + + return elements == null ? null : new SequenceValue(elements); + } } } \ No newline at end of file diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs index bfcfd85..d56fe2e 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs @@ -1,4 +1,18 @@ -using System; +// 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; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -124,25 +138,22 @@ protected override ExpressionBody Transform(AmbientPropertyExpression px) { if (px.IsBuiltIn) { - if (px.PropertyName == BuiltInProperty.Level) - return Splice(context => new ScalarValue(context.Level)); - - if (px.PropertyName == BuiltInProperty.Message) - return Splice(context => new ScalarValue(Intrinsics.RenderMessage(context))); - - if (px.PropertyName == BuiltInProperty.Exception) - return Splice(context => context.Exception == null ? null : new ScalarValue(context.Exception)); - - if (px.PropertyName == BuiltInProperty.Timestamp) - return Splice(context => new ScalarValue(context.Timestamp)); - - if (px.PropertyName == BuiltInProperty.MessageTemplate) - return Splice(context => new ScalarValue(context.MessageTemplate.Text)); - - if (px.PropertyName == BuiltInProperty.Properties) - return Splice(context => new StructureValue(context.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)), null)); - - return LX.Constant(null, typeof(LogEventPropertyValue)); + return px.PropertyName switch + { + BuiltInProperty.Level => Splice(context => new ScalarValue(context.Level)), + BuiltInProperty.Message => Splice(context => new ScalarValue(Intrinsics.RenderMessage(context))), + BuiltInProperty.Exception => Splice(context => + context.Exception == null ? null : new ScalarValue(context.Exception)), + BuiltInProperty.Timestamp => Splice(context => new ScalarValue(context.Timestamp)), + BuiltInProperty.MessageTemplate => Splice(context => new ScalarValue(context.MessageTemplate.Text)), + BuiltInProperty.Properties => Splice(context => + new StructureValue(context.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)), + null)), + BuiltInProperty.Renderings => Splice(context => Intrinsics.GetRenderings(context)), + BuiltInProperty.EventId => Splice(context => + new ScalarValue(EventIdHash.Compute(context.MessageTemplate.Text))), + _ => LX.Constant(null, typeof(LogEventPropertyValue)) + }; } var propertyName = px.PropertyName; diff --git a/src/Serilog.Expressions/Expressions/Compilation/Text/LikeSyntaxTransformer.cs b/src/Serilog.Expressions/Expressions/Compilation/Text/LikeSyntaxTransformer.cs index 863b6fb..e4a4463 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Text/LikeSyntaxTransformer.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Text/LikeSyntaxTransformer.cs @@ -54,7 +54,10 @@ cx.Constant is ScalarValue scalar && static string LikeToRegex(string like) { + var begin = "^"; var regex = ""; + var end = "$"; + for (var i = 0; i < like.Length; ++i) { var ch = like[i]; @@ -68,7 +71,17 @@ static string LikeToRegex(string like) } else { - regex += "(?:.|\\r|\\n)*"; // ~= RegexOptions.Singleline + if (i == 0) + begin = ""; + + if (i == like.Length - 1) + end = ""; + + if (i == 0 && i == like.Length - 1) + regex += ".*"; + + if (i != 0 && i != like.Length - 1) + regex += "(?:.|\\r|\\n)*"; // ~= RegexOptions.Singleline } } else if (ch == '_') @@ -87,7 +100,7 @@ static string LikeToRegex(string like) regex += Regex.Escape(ch.ToString()); } - return regex; + return begin + regex + end; } } } diff --git a/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs b/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs index 1bcaf0d..51a4ecd 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs @@ -1,4 +1,5 @@ -using Serilog.Expressions.Ast; +using System.Linq; +using Serilog.Expressions.Ast; using Serilog.Expressions.Compilation.Transformations; namespace Serilog.Expressions.Compilation.Wildcards @@ -13,27 +14,45 @@ public static Expression Expand(Expression root) return wc.Transform(root); } + // This matches expression fragments such as `A[?] = 'test'` and + // transforms them into `any(A, |p| p = 'test)`. + // + // As the comparand in such expressions can be complex, e.g. + // `Substring(A[?], 0, 4) = 'test')`, the search for `?` and `*` wildcards + // is deep, but, it terminates upon reaching any other wildcard-compatible + // comparison. Thus `(A[?] = 'test') = true` will result in `any(A, |p| p = 'test') = true` and + // not `any(A, |p| (p = 'test') = true)`, which is important because short-circuiting when the first + // argument to `any()` is undefined will change the semantics of the resulting expression, otherwise. protected override Expression Transform(CallExpression lx) { - if (!Operators.WildcardComparators.Contains(lx.OperatorName) || lx.Operands.Length != 2) + if (!Operators.WildcardComparators.Contains(lx.OperatorName)) return base.Transform(lx); - var lhsIs = WildcardSearch.FindElementAtWildcard(lx.Operands[0]); - var rhsIs = WildcardSearch.FindElementAtWildcard(lx.Operands[1]); - if (lhsIs != null && rhsIs != null || lhsIs == null && rhsIs == null) + IndexerExpression? indexer = null; + Expression? wildcardPath = null; + var indexerOperand = -1; + for (var i = 0; i < lx.Operands.Length; ++i) + { + indexer = WildcardSearch.FindWildcardIndexer(lx.Operands[i]); + if (indexer != null) + { + indexerOperand = i; + wildcardPath = lx.Operands[i]; + break; + } + } + + if (indexer == null || wildcardPath == null) return base.Transform(lx); // N/A, or invalid - var wildcardPath = lhsIs != null ? lx.Operands[0] : lx.Operands[1]; - var comparand = lhsIs != null ? lx.Operands[1] : lx.Operands[0]; - var indexer = lhsIs ?? rhsIs!; - var px = new ParameterExpression("p" + _nextParameter++); var nestedComparand = NodeReplacer.Replace(wildcardPath, indexer, px); var coll = indexer.Receiver; var wc = ((IndexerWildcardExpression)indexer.Index).Wildcard; - var comparisonArgs = lhsIs != null ? new[] { nestedComparand, comparand } : new[] { comparand, nestedComparand }; + var comparisonArgs = lx.Operands.ToArray(); + comparisonArgs[indexerOperand] = nestedComparand; var body = new CallExpression(lx.IgnoreCase, lx.OperatorName, comparisonArgs); var lambda = new LambdaExpression(new[] { px }, body); @@ -42,5 +61,19 @@ protected override Expression Transform(CallExpression lx) var call = new CallExpression(false, op, coll, lambda); return Transform(call); } + + // Detects and transforms standalone `A[?]` fragments that are not part of a comparision; these + // are effectively Boolean tests. + protected override Expression Transform(IndexerExpression ix) + { + if (!(ix.Index is IndexerWildcardExpression wx)) + return base.Transform(ix); + + var px = new ParameterExpression("p" + _nextParameter++); + var coll = Transform(ix.Receiver); + var lambda = new LambdaExpression(new[] { px }, px); + var op = Operators.ToRuntimeWildcardOperator(wx.Wildcard); + return new CallExpression(false, op, coll, lambda); + } } } diff --git a/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardSearch.cs b/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardSearch.cs index 5e3b2ec..9b07536 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardSearch.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardSearch.cs @@ -8,7 +8,7 @@ class WildcardSearch : SerilogExpressionTransformer { static readonly WildcardSearch Instance = new WildcardSearch(); - public static IndexerExpression? FindElementAtWildcard(Expression fx) + public static IndexerExpression? FindWildcardIndexer(Expression fx) { return Instance.Transform(fx); } @@ -59,6 +59,11 @@ class WildcardSearch : SerilogExpressionTransformer protected override IndexerExpression? Transform(CallExpression lx) { + // If we hit a wildcard-compatible operation, then any wildcards within its operands "belong" to + // it and can't be the result of this search. + if (Operators.WildcardComparators.Contains(lx.OperatorName)) + return null; + return lx.Operands.Select(Transform).FirstOrDefault(e => e != null); } diff --git a/src/Serilog.Expressions/Expressions/Helpers.cs b/src/Serilog.Expressions/Expressions/Helpers.cs new file mode 100644 index 0000000..368f816 --- /dev/null +++ b/src/Serilog.Expressions/Expressions/Helpers.cs @@ -0,0 +1,44 @@ +// 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. + +#if NETSTANDARD2_0 + +using System; + +namespace Serilog.Expressions +{ + /// + /// Helper methods. + /// + internal static class Helpers + { + /// + /// Backport .NET Standard 2.1 additions to maintain .NET Standard 2.0 compatibility. + /// Returns a value indicating whether a specified string occurs within this string, using the specified comparison rules. + /// + /// From; + /// https://github.com/dotnet/runtime/issues/22198 + /// https://stackoverflow.com/questions/444798/case-insensitive-containsstring/444818#444818 + /// + /// input string + /// The string to seek. + /// Specifies the rule to use in the comparison. + /// + public static bool Contains(this string source, string value, StringComparison comparisonType) + { + return source?.IndexOf(value, comparisonType) >= 0; + } + } +} +#endif \ No newline at end of file diff --git a/src/Serilog.Expressions/Expressions/Operators.cs b/src/Serilog.Expressions/Expressions/Operators.cs index cd22499..b8b7d30 100644 --- a/src/Serilog.Expressions/Expressions/Operators.cs +++ b/src/Serilog.Expressions/Expressions/Operators.cs @@ -24,12 +24,15 @@ static class Operators public const string OpIsDefined = "IsDefined"; public const string OpLastIndexOf = "LastIndexOf"; public const string OpLength = "Length"; + public const string OpNow = "Now"; public const string OpRound = "Round"; public const string OpStartsWith = "StartsWith"; public const string OpSubstring = "Substring"; public const string OpTagOf = "TagOf"; + public const string OpToString = "ToString"; public const string OpTypeOf = "TypeOf"; public const string OpUndefined = "Undefined"; + public const string OpUtcDateTime = "UtcDateTime"; public const string IntermediateOpLike = "_Internal_Like"; public const string IntermediateOpNotLike = "_Internal_NotLike"; diff --git a/src/Serilog.Expressions/Expressions/Parsing/ExpressionParser.cs b/src/Serilog.Expressions/Expressions/Parsing/ExpressionParser.cs index 6c66f9f..eeca008 100644 --- a/src/Serilog.Expressions/Expressions/Parsing/ExpressionParser.cs +++ b/src/Serilog.Expressions/Expressions/Parsing/ExpressionParser.cs @@ -8,9 +8,9 @@ static class ExpressionParser { static ExpressionTokenizer Tokenizer { get; } = new ExpressionTokenizer(); - public static Expression Parse(string filterExpression) + public static Expression Parse(string expression) { - if (!TryParse(filterExpression, out var root, out var error)) + if (!TryParse(expression, out var root, out var error)) throw new ArgumentException(error); return root; diff --git a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs index 7cdc21d..580fa2b 100644 --- a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs +++ b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs @@ -467,5 +467,38 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v { return Coerce.IsTrue(condition) ? consequent : alternative; } + + public static LogEventPropertyValue? ToString(LogEventPropertyValue? value, LogEventPropertyValue? format) + { + if (!(value is ScalarValue sv && sv.Value is IFormattable formattable) || + !Coerce.String(format, out var fmt)) + { + return null; + } + + // TODO #19: formatting is culture-specific. + return new ScalarValue(formattable.ToString(fmt, null)); + } + + public static LogEventPropertyValue? UtcDateTime(LogEventPropertyValue? dateTime) + { + if (dateTime is ScalarValue sv) + { + if (sv.Value is DateTimeOffset dto) + return new ScalarValue(dto.UtcDateTime); + + if (sv.Value is DateTime dt) + return new ScalarValue(dt.ToUniversalTime()); + } + + return null; + } + + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? Now() + { + // DateTimeOffset.Now is the generator for LogEvent.Timestamp. + return new ScalarValue(DateTimeOffset.Now); + } } } diff --git a/src/Serilog.Expressions/Expressions/SerilogExpression.cs b/src/Serilog.Expressions/Expressions/SerilogExpression.cs index ae420d4..b8e2a25 100644 --- a/src/Serilog.Expressions/Expressions/SerilogExpression.cs +++ b/src/Serilog.Expressions/Expressions/SerilogExpression.cs @@ -13,7 +13,6 @@ // limitations under the License. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Serilog.Expressions.Compilation; diff --git a/src/Serilog.Expressions/Serilog.Expressions.csproj b/src/Serilog.Expressions/Serilog.Expressions.csproj index 3445ec0..5dfdc9c 100644 --- a/src/Serilog.Expressions/Serilog.Expressions.csproj +++ b/src/Serilog.Expressions/Serilog.Expressions.csproj @@ -3,9 +3,9 @@ An embeddable mini-language for filtering, enriching, and formatting Serilog events, ideal for use with JSON or XML configuration. - 1.0.0 + 1.1.0 Serilog Contributors - netstandard2.1 + netstandard2.0;netstandard2.1 true true Serilog diff --git a/src/Serilog.Expressions/System.Diagnostics.CodeAnalysis/NotNullAttributes.cs b/src/Serilog.Expressions/System.Diagnostics.CodeAnalysis/NotNullAttributes.cs new file mode 100644 index 0000000..f2b79ba --- /dev/null +++ b/src/Serilog.Expressions/System.Diagnostics.CodeAnalysis/NotNullAttributes.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NETSTANDARD2_0 +//From: https://medium.com/@SergioPedri/enabling-and-using-c-9-features-on-older-and-unsupported-runtimes-ce384d8debb +namespace System.Diagnostics.CodeAnalysis +{ + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute { } + + /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } +} +#endif \ No newline at end of file diff --git a/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv b/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv index a783b3e..cafaa5d 100644 --- a/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv +++ b/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv @@ -217,7 +217,13 @@ if undefined() then 1 else 2 ⇶ 2 if 'string' then 1 else 2 ⇶ 2 if true then if false then 1 else 2 else 3 ⇶ 2 -// Typeof +// ToString +tostring(16, '000') ⇶ '016' +tostring('test', '000') ⇶ undefined() +tostring(16, undefined()) ⇶ undefined() +tostring(16, null) ⇶ undefined() + +// TypeOf typeof(undefined()) ⇶ 'undefined' typeof('test') ⇶ 'System.String' typeof(10) ⇶ 'System.Decimal' @@ -226,6 +232,9 @@ typeof(null) ⇶ 'null' typeof([]) ⇶ 'array' typeof({}) ⇶ 'object' +// UtcDateTime +tostring(utcdatetime(now()), 'o') like '20%' ⇶ true + // Case comparison 'test' = 'TEST' ⇶ false 'tschüß' = 'TSCHÜSS' ⇶ false @@ -258,3 +267,7 @@ undefined() = undefined() ci ⇶ undefined() 'test' like '%s_' ⇶ true 'test' like '%' ⇶ true 'test' like 't%s%' ⇶ true +'test' like 'es' ⇶ false +'test' like '%' ⇶ true +'test' like '' ⇶ false +'' like '' ⇶ true diff --git a/test/Serilog.Expressions.Tests/Cases/translation-cases.asv b/test/Serilog.Expressions.Tests/Cases/translation-cases.asv new file mode 100644 index 0000000..7163e7d --- /dev/null +++ b/test/Serilog.Expressions.Tests/Cases/translation-cases.asv @@ -0,0 +1,20 @@ +// Like +A like 'a' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, '^a$'), -1) +A like 'a%' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, '^a'), -1) +A like '%a%' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, 'a'), -1) +A like '%a' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, 'a$'), -1) +A like '%a_b%' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, 'a.b'), -1) +A like 'a%b%' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, '^a(?:.|\r|\n)*b'), -1) +A like '%' ⇶ _Internal_NotEqual(_Internal_IndexOfMatch(A, '.*'), -1) + +// Root properties +@p['Test'] ⇶ Test +@p[Test] ⇶ @p[Test] + +// Variadics +coalesce(A, B, C, D) ⇶ coalesce(A, coalesce(B, coalesce(C, D))) + +// Wildcards! +A[?] ⇶ _Internal_Any(A, |$$p0| {$$p0}) +A or B[*] ⇶ _Internal_Or(A, _Internal_All(B, |$$p0| {$$p0})) +not (A is not null) or not (A[?] = 'a') ⇶ _Internal_Or(_Internal_Not(_Internal_IsNotNull(A)), _Internal_Not(_Internal_Any(A, |$$p0| {_Internal_Equal($$p0, 'a')}))) diff --git a/test/Serilog.Expressions.Tests/ExpressionEvaluationTests.cs b/test/Serilog.Expressions.Tests/ExpressionEvaluationTests.cs index 5c33a79..0532a9b 100644 --- a/test/Serilog.Expressions.Tests/ExpressionEvaluationTests.cs +++ b/test/Serilog.Expressions.Tests/ExpressionEvaluationTests.cs @@ -11,20 +11,8 @@ namespace Serilog.Expressions.Tests { public class ExpressionEvaluationTests { - static readonly string CasesPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory!, "Cases"); - - static IEnumerable ReadCases(string filename) - { - foreach (var line in File.ReadLines(Path.Combine(CasesPath, filename))) - { - var cols = line.Split("⇶", StringSplitOptions.RemoveEmptyEntries); - if (cols.Length == 2) - yield return cols.Select(c => c.Trim()).ToArray(); - } - } - public static IEnumerable ExpressionEvaluationCases => - ReadCases("expression-evaluation-cases.asv"); + AsvCases.ReadCases("expression-evaluation-cases.asv"); [Theory] [MemberData(nameof(ExpressionEvaluationCases))] diff --git a/test/Serilog.Expressions.Tests/ExpressionTranslationTests.cs b/test/Serilog.Expressions.Tests/ExpressionTranslationTests.cs new file mode 100644 index 0000000..27cc62e --- /dev/null +++ b/test/Serilog.Expressions.Tests/ExpressionTranslationTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Serilog.Events; +using Serilog.Expressions.Compilation; +using Serilog.Expressions.Parsing; +using Serilog.Expressions.Runtime; +using Serilog.Expressions.Tests.Support; +using Xunit; + +namespace Serilog.Expressions.Tests +{ + public class ExpressionTranslationTests + { + public static IEnumerable ExpressionEvaluationCases => + AsvCases.ReadCases("translation-cases.asv"); + + [Theory] + [MemberData(nameof(ExpressionEvaluationCases))] + public void ExpressionsAreCorrectlyTranslated(string expr, string expected) + { + var parsed = ExpressionParser.Parse(expr); + var translated = ExpressionCompiler.Translate(parsed); + var actual = translated.ToString(); + Assert.Equal(expected, actual); + } + } +} diff --git a/test/Serilog.Expressions.Tests/FormatParityTests.cs b/test/Serilog.Expressions.Tests/FormatParityTests.cs new file mode 100644 index 0000000..30ff858 --- /dev/null +++ b/test/Serilog.Expressions.Tests/FormatParityTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Serilog.Events; +using Serilog.Expressions.Tests.Support; +using Serilog.Formatting; +using Serilog.Formatting.Compact; +using Serilog.Formatting.Json; +using Serilog.Parsing; +using Serilog.Templates; +using Xunit; + +namespace Serilog.Expressions.Tests +{ + /// + /// These tests track the ability of Serilog.Expressions to faithfully reproduce the JSON formats implemented in + /// Serilog and Serilog.Formatting.Compact. The tests jump through a few hoops to achieve byte-for-byte correctness; + /// in practice, valid JSON in these formats can be constructed with simpler templates. + /// + public class FormatParityTests + { + // Implements CLEF-style `@@` escaping of property names that begin with `@`. + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? ClefEscape(LogEventPropertyValue? logEventProperties) + { + if (!(logEventProperties is StructureValue st)) + return null; + + foreach (var check in st.Properties) + { + if (check.Name.Length > 0 && check.Name[0] == '@') + { + var properties = new List(); + + foreach (var member in st.Properties) + { + var property = new LogEventProperty( + member.Name.Length > 0 && member.Name[0] == '@' ? "@" + member.Name : member.Name, + member.Value); + + properties.Add(property); + } + + return new StructureValue(properties, st.TypeTag); + } + } + + return logEventProperties; + } + + // Renders a message template with old-style "quoted" strings (expression templates use the newer :lj formatting always). + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? ClassicRender(LogEventPropertyValue? messageTemplate, LogEventPropertyValue? properties) + { + if (!(messageTemplate is ScalarValue svt && svt.Value is string smt) || + !(properties is StructureValue stp)) + { + return null; + } + + var mt = new MessageTemplateParser().Parse(smt); + var space = new StringWriter(); + mt.Render(stp.Properties.ToDictionary(p => p.Name, p => p.Value), space); + return new ScalarValue(space.ToString()); + } + + // Constructs the Renderings property used in the old JSON format. + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? ClassicRenderings(LogEventPropertyValue? messageTemplate, LogEventPropertyValue? properties) + { + if (!(messageTemplate is ScalarValue svt && svt.Value is string smt) || + !(properties is StructureValue stp)) + { + return null; + } + + var mt = new MessageTemplateParser().Parse(smt); + var tokensWithFormat = mt.Tokens + .OfType() + .Where(pt => pt.Format != null) + .GroupBy(pt => pt.PropertyName); + + // ReSharper disable once PossibleMultipleEnumeration + if (!tokensWithFormat.Any()) + return null; + + var propertiesByName = stp.Properties.ToDictionary(p => p.Name, p => p.Value); + + var renderings = new List(); + // ReSharper disable once PossibleMultipleEnumeration + foreach (var propertyFormats in tokensWithFormat) + { + var values = new List(); + + foreach (var format in propertyFormats) + { + var sw = new StringWriter(); + + format.Render(propertiesByName, sw); + + values.Add(new StructureValue(new [] + { + new LogEventProperty("Format", new ScalarValue(format.Format)), + new LogEventProperty("Rendering", new ScalarValue(sw.ToString())), + })); + } + + renderings.Add(new LogEventProperty(propertyFormats.Key, new SequenceValue(values))); + } + + return new StructureValue(renderings); + } + + readonly ITextFormatter + _clef = new CompactJsonFormatter(), + _renderedClef = new RenderedCompactJsonFormatter(), + _classic = new JsonFormatter(), + _clefExpression = new ExpressionTemplate( + "{ {@t: UtcDateTime(@t), @mt, @r, @l: if @l = 'Information' then undefined() else @l, @x, ..ClefEscape(@p)} }" + Environment.NewLine, + null, new StaticMemberNameResolver(typeof(FormatParityTests))), + _renderedClefExpression = new ExpressionTemplate( + "{ {@t: UtcDateTime(@t), @m: ClassicRender(@mt, @p), @i: ToString(@i, 'x8'), @l: if @l = 'Information' then undefined() else @l, @x, ..ClefEscape(@p)} }" + Environment.NewLine, + null, new StaticMemberNameResolver(typeof(FormatParityTests))), + _classicExpression = new ExpressionTemplate( + "{ {Timestamp: @t, Level: @l, MessageTemplate: @mt, Exception: @x, Properties: if IsDefined(@p[?]) then @p else undefined(), Renderings: ClassicRenderings(@mt, @p)} }" + Environment.NewLine, + null, new StaticMemberNameResolver(typeof(FormatParityTests))); + + static string Render( + ITextFormatter formatter, + LogEvent logEvent) + { + var space = new StringWriter(); + formatter.Format(logEvent, space); + return space.ToString(); + } + + void AssertWriteParity( + LogEventLevel level, + Exception? exception, + string messageTemplate, + params object[] propertyValues) + { + var sink = new CollectingSink(); + using (var log = new LoggerConfiguration() + .MinimumLevel.Is(LevelAlias.Minimum) + .WriteTo.Sink(sink) + .CreateLogger()) + { + log.Write(level, exception, messageTemplate, propertyValues); + } + + var clef = Render(_clef, sink.SingleEvent); + var clefExpression = Render(_clefExpression, sink.SingleEvent); + Assert.Equal(clef, clefExpression); + + var renderedClef = Render(_renderedClef, sink.SingleEvent); + var renderedClefExpression = Render(_renderedClefExpression, sink.SingleEvent); + Assert.Equal(renderedClef, renderedClefExpression); + + var renderedClassic = Render(_classic, sink.SingleEvent); + var renderedClassicExpression = Render(_classicExpression, sink.SingleEvent); + Assert.Equal(renderedClassic, renderedClassicExpression); + } + + [Fact] + public void ParityIsMaintained() + { + AssertWriteParity(LogEventLevel.Information, null, "Hello, world!"); + AssertWriteParity(LogEventLevel.Debug, new Exception(), "Hello, {Name}, {Number:000}, {Another:#.00}", "world", 42, 3.1); + } + } +} diff --git a/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj b/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj index 1a98f8f..5b3e4ec 100644 --- a/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj +++ b/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1 + net5.0 true ../../assets/Serilog.snk true @@ -12,6 +12,7 @@ + diff --git a/test/Serilog.Expressions.Tests/Support/AsvCases.cs b/test/Serilog.Expressions.Tests/Support/AsvCases.cs new file mode 100644 index 0000000..7438085 --- /dev/null +++ b/test/Serilog.Expressions.Tests/Support/AsvCases.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Serilog.Expressions.Tests.Support +{ + // "Arrow-separated values ;-) ... convenient because the Unicode `⇶` character doesn't appear in + // any of the cases themselves, and so we ignore any need for special character escaping (which is + // troublesome when the language the cases are written in uses just about all special characters somehow + // or other!). + // + // The ASV format informally supports `//` comment lines, as long as they don't contain the arrow character. + static class AsvCases + { + static readonly string CasesPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory!, "Cases"); + + public static IEnumerable ReadCases(string filename) + { + return from line in File.ReadLines(Path.Combine(CasesPath, filename)) + select line.Split("⇶", StringSplitOptions.RemoveEmptyEntries) into cols + where cols.Length == 2 + select cols.Select(c => c.Trim()).ToArray(); + } + } +} diff --git a/test/Serilog.Expressions.Tests/TemplateEvaluationTests.cs b/test/Serilog.Expressions.Tests/TemplateEvaluationTests.cs index 90afd68..8acc9d4 100644 --- a/test/Serilog.Expressions.Tests/TemplateEvaluationTests.cs +++ b/test/Serilog.Expressions.Tests/TemplateEvaluationTests.cs @@ -10,20 +10,8 @@ namespace Serilog.Expressions.Tests { public class TemplateEvaluationTests { - static readonly string CasesPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory!, "Cases"); - - static IEnumerable ReadCases(string filename) - { - foreach (var line in File.ReadLines(Path.Combine(CasesPath, filename))) - { - var cols = line.Split("⇶", StringSplitOptions.RemoveEmptyEntries); - if (cols.Length == 2) - yield return cols.Select(c => c.Trim()).ToArray(); - } - } - public static IEnumerable TemplateEvaluationCases => - ReadCases("template-evaluation-cases.asv"); + AsvCases.ReadCases("template-evaluation-cases.asv"); [Theory] [MemberData(nameof(TemplateEvaluationCases))]