diff --git a/README.md b/README.md index 5acac2a..4f6c8c6 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ The built-in properties mirror those available in the CLEF format. | Array | An array of values, in square brackets | `[1, 'two', null]` | | Object | A mapping of string keys to values; keys that are valid identifiers do not need to be quoted | `{a: 1, 'b c': 2, d}` | -Array and object literals support the spread operator: `[1, 2, ..rest]`, `{a: 1, ..other}`. Specifying an undefined +Array and object literals support the spread operator: `[1, 2, ..others]`, `{a: 1, ..others}`. Specifying an undefined property in an object literal will remove it from the result: `{..User, Email: Undefined()}` ### Operators and conditionals @@ -196,7 +196,7 @@ calling a function will be undefined if: | `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`. | -| `Rest()` | In an `ExpressionTemplate`, returns an object containing the first-class event properties not otherwise referenced in the template or the event's message. | +| `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. | | `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. | diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs index 7744406..e898b00 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs @@ -17,6 +17,8 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; using Serilog.Events; using Serilog.Expressions.Ast; using Serilog.Expressions.Compilation.Transformations; @@ -27,6 +29,7 @@ using ParameterExpression = System.Linq.Expressions.ParameterExpression; using LX = System.Linq.Expressions.Expression; using ExpressionBody = System.Linq.Expressions.Expression; +// ReSharper disable UseIndexFromEndExpression namespace Serilog.Expressions.Compilation.Linq { @@ -101,11 +104,18 @@ protected override ExpressionBody Transform(CallExpression call) if (!_nameResolver.TryResolveFunctionName(call.OperatorName, out var m)) throw new ArgumentException($"The function name `{call.OperatorName}` was not recognized."); - var methodParameters = m.GetParameters(); + var methodParameters = m.GetParameters() + .Select(info => (pi: info, optional: info.GetCustomAttribute() != null)) + .ToList(); - var parameterCount = methodParameters.Count(pi => pi.ParameterType == typeof(LogEventPropertyValue)); - if (parameterCount != call.Operands.Length) - throw new ArgumentException($"The function `{call.OperatorName}` requires {parameterCount} arguments."); + var allowedParameters = methodParameters.Where(info => info.pi.ParameterType == typeof(LogEventPropertyValue)).ToList(); + var requiredParameterCount = allowedParameters.Count(info => !info.optional); + + if (call.Operands.Length < requiredParameterCount || call.Operands.Length > allowedParameters.Count) + { + var requirements = DescribeRequirements(allowedParameters.Select(info => (info.pi.Name!, info.optional)).ToList()); + throw new ArgumentException($"The function `{call.OperatorName}` {requirements}."); + } var operands = new Queue(call.Operands.Select(Transform)); @@ -116,11 +126,15 @@ protected override ExpressionBody Transform(CallExpression call) if (Operators.SameOperator(call.OperatorName, Operators.RuntimeOpOr)) return CompileLogical(LX.OrElse, operands.Dequeue(), operands.Dequeue()); - var boundParameters = new List(methodParameters.Length); - foreach (var pi in methodParameters) + var boundParameters = new List(methodParameters.Count); + foreach (var (pi, optional) in methodParameters) { if (pi.ParameterType == typeof(LogEventPropertyValue)) - boundParameters.Add(operands.Dequeue()); + { + boundParameters.Add(operands.Count > 0 + ? operands.Dequeue() + : LX.Constant(null, typeof(LogEventPropertyValue))); + } else if (pi.ParameterType == typeof(StringComparison)) boundParameters.Add(LX.Constant(call.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); else if (pi.ParameterType == typeof(IFormatProvider)) @@ -129,13 +143,38 @@ protected override ExpressionBody Transform(CallExpression call) boundParameters.Add(LX.Property(Context, EvaluationContextLogEventProperty)); else if (_nameResolver.TryBindFunctionParameter(pi, out var binding)) boundParameters.Add(LX.Constant(binding, pi.ParameterType)); + else if (optional) + boundParameters.Add(LX.Constant( + pi.GetCustomAttribute()?.Value, pi.ParameterType)); else - throw new ArgumentException($"The method `{m.Name}` implementing function `{call.OperatorName}` has parameter `{pi.Name}` which could not be bound."); + throw new ArgumentException($"The method `{m.Name}` implementing function `{call.OperatorName}` has argument `{pi.Name}` which could not be bound."); } return LX.Call(m, boundParameters); } + static string DescribeRequirements(IReadOnlyList<(string name, bool optional)> parameters) + { + static string DescribeArgument((string name, bool optional) p) => + $"`{p.name}`" + (p.optional ? " (optional)" : ""); + + if (parameters.Count == 0) + return "accepts no arguments"; + + if (parameters.Count == 1) + return $"accepts one argument, {DescribeArgument(parameters[0])}"; + + if (parameters.Count == 2) + return $"accepts two arguments, {DescribeArgument(parameters[0])} and {DescribeArgument(parameters[1])}"; + + var result = new StringBuilder("accepts arguments"); + for (var i = 0; i < parameters.Count - 1; ++i) + result.Append($" {DescribeArgument(parameters[i])},"); + + result.Append($" and {DescribeArgument(parameters[parameters.Count - 1])}"); + return result.ToString(); + } + static ExpressionBody CompileLogical(Func apply, ExpressionBody lhs, ExpressionBody rhs) { return LX.Convert( diff --git a/src/Serilog.Expressions/Expressions/Compilation/Variadics/VariadicCallRewriter.cs b/src/Serilog.Expressions/Expressions/Compilation/Variadics/VariadicCallRewriter.cs index 560a8df..0d999d4 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Variadics/VariadicCallRewriter.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Variadics/VariadicCallRewriter.cs @@ -30,20 +30,11 @@ public static Expression Rewrite(Expression expression) protected override Expression Transform(CallExpression call) { - if (Operators.SameOperator(call.OperatorName, Operators.OpSubstring) && call.Operands.Length == 2) - { - var operands = call.Operands - .Select(Transform) - .Concat(new[] {CallUndefined()}) - .ToArray(); - return new CallExpression(call.IgnoreCase, call.OperatorName, operands); - } - if (Operators.SameOperator(call.OperatorName, Operators.OpCoalesce) || Operators.SameOperator(call.OperatorName, Operators.OpConcat)) { if (call.Operands.Length == 0) - return CallUndefined(); + return new CallExpression(false, Operators.OpUndefined); if (call.Operands.Length == 1) return Transform(call.Operands.Single()); if (call.Operands.Length > 2) @@ -54,18 +45,7 @@ protected override Expression Transform(CallExpression call) } } - if (Operators.SameOperator(call.OperatorName, Operators.OpToString) && - call.Operands.Length == 1) - { - return new CallExpression(call.IgnoreCase, call.OperatorName, call.Operands[0], CallUndefined()); - } - return base.Transform(call); } - - static CallExpression CallUndefined() - { - return new(false, Operators.OpUndefined); - } } } diff --git a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs index 2f1d717..a9159aa 100644 --- a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs +++ b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs @@ -236,9 +236,9 @@ static bool UnboxedEqualHelper(StringComparison sc, LogEventPropertyValue? left, return null; } - public static LogEventPropertyValue? Round(LogEventPropertyValue? value, LogEventPropertyValue? places) + public static LogEventPropertyValue? Round(LogEventPropertyValue? number, LogEventPropertyValue? places) { - if (!Coerce.Numeric(value, out var v) || + if (!Coerce.Numeric(number, out var v) || !Coerce.Numeric(places, out var p) || p < 0 || p > 32) // Check my memory, here :D @@ -266,57 +266,57 @@ static bool UnboxedEqualHelper(StringComparison sc, LogEventPropertyValue? left, null; } - public static LogEventPropertyValue? Contains(StringComparison sc, LogEventPropertyValue? corpus, LogEventPropertyValue? pattern) + public static LogEventPropertyValue? Contains(StringComparison sc, LogEventPropertyValue? @string, LogEventPropertyValue? substring) { - if (!Coerce.String(corpus, out var ctx) || - !Coerce.String(pattern, out var ptx)) + if (!Coerce.String(@string, out var ctx) || + !Coerce.String(substring, out var ptx)) return null; return ScalarBoolean(ctx.Contains(ptx, sc)); } - public static LogEventPropertyValue? IndexOf(StringComparison sc, LogEventPropertyValue? corpus, LogEventPropertyValue? pattern) + public static LogEventPropertyValue? IndexOf(StringComparison sc, LogEventPropertyValue? @string, LogEventPropertyValue? substring) { - if (!Coerce.String(corpus, out var ctx) || - !Coerce.String(pattern, out var ptx)) + if (!Coerce.String(@string, out var ctx) || + !Coerce.String(substring, out var ptx)) return null; return new ScalarValue(ctx.IndexOf(ptx, sc)); } - public static LogEventPropertyValue? LastIndexOf(StringComparison sc, LogEventPropertyValue? corpus, LogEventPropertyValue? pattern) + public static LogEventPropertyValue? LastIndexOf(StringComparison sc, LogEventPropertyValue? @string, LogEventPropertyValue? substring) { - if (!Coerce.String(corpus, out var ctx) || - !Coerce.String(pattern, out var ptx)) + if (!Coerce.String(@string, out var ctx) || + !Coerce.String(substring, out var ptx)) return null; return new ScalarValue(ctx.LastIndexOf(ptx, sc)); } - public static LogEventPropertyValue? Length(LogEventPropertyValue? arg) + public static LogEventPropertyValue? Length(LogEventPropertyValue? value) { - if (Coerce.String(arg, out var s)) + if (Coerce.String(value, out var s)) return new ScalarValue(s.Length); - if (arg is SequenceValue seq) + if (value is SequenceValue seq) return new ScalarValue(seq.Elements.Count); return null; } - public static LogEventPropertyValue? StartsWith(StringComparison sc, LogEventPropertyValue? corpus, LogEventPropertyValue? pattern) + public static LogEventPropertyValue? StartsWith(StringComparison sc, LogEventPropertyValue? value, LogEventPropertyValue? substring) { - if (!Coerce.String(corpus, out var ctx) || - !Coerce.String(pattern, out var ptx)) + if (!Coerce.String(value, out var ctx) || + !Coerce.String(substring, out var ptx)) return null; return ScalarBoolean(ctx.StartsWith(ptx, sc)); } - public static LogEventPropertyValue? EndsWith(StringComparison sc, LogEventPropertyValue? corpus, LogEventPropertyValue? pattern) + public static LogEventPropertyValue? EndsWith(StringComparison sc, LogEventPropertyValue? value, LogEventPropertyValue? substring) { - if (!Coerce.String(corpus, out var ctx) || - !Coerce.String(pattern, out var ptx)) + if (!Coerce.String(value, out var ctx) || + !Coerce.String(substring, out var ptx)) return null; return ScalarBoolean(ctx.EndsWith(ptx, sc)); @@ -432,17 +432,17 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v } // Ideally this will be compiled as a short-circuiting intrinsic - public static LogEventPropertyValue? Coalesce(LogEventPropertyValue? v1, LogEventPropertyValue? v2) + public static LogEventPropertyValue? Coalesce(LogEventPropertyValue? value0, LogEventPropertyValue? value1) { - if (v1 is null or ScalarValue {Value: null}) - return v2; + if (value0 is null or ScalarValue {Value: null}) + return value1; - return v1; + return value0; } - public static LogEventPropertyValue? Substring(LogEventPropertyValue? sval, LogEventPropertyValue? startIndex, LogEventPropertyValue? length) + public static LogEventPropertyValue? Substring(LogEventPropertyValue? @string, LogEventPropertyValue? startIndex, LogEventPropertyValue? length = null) { - if (!Coerce.String(sval, out var str) || + if (!Coerce.String(@string, out var str) || !Coerce.Numeric(startIndex, out var si)) return null; @@ -461,9 +461,9 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v return new ScalarValue(str.Substring((int)si, (int)len)); } - public static LogEventPropertyValue? Concat(LogEventPropertyValue? first, LogEventPropertyValue? second) + public static LogEventPropertyValue? Concat(LogEventPropertyValue? string0, LogEventPropertyValue? string1) { - if (Coerce.String(first, out var f) && Coerce.String(second, out var s)) + if (Coerce.String(string0, out var f) && Coerce.String(string1, out var s)) { return new ScalarValue(f + s); } @@ -492,7 +492,7 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v return Coerce.IsTrue(condition) ? consequent : alternative; } - public static LogEventPropertyValue? ToString(IFormatProvider? formatProvider, LogEventPropertyValue? value, LogEventPropertyValue? format) + public static LogEventPropertyValue? ToString(IFormatProvider? formatProvider, LogEventPropertyValue? value, LogEventPropertyValue? format = null) { if (value is not ScalarValue sv || sv.Value == null || diff --git a/src/Serilog.Expressions/Templates/Compilation/UnreferencedProperties/UnreferencedPropertiesFunction.cs b/src/Serilog.Expressions/Templates/Compilation/UnreferencedProperties/UnreferencedPropertiesFunction.cs index e5c9fc5..301eb1b 100644 --- a/src/Serilog.Expressions/Templates/Compilation/UnreferencedProperties/UnreferencedPropertiesFunction.cs +++ b/src/Serilog.Expressions/Templates/Compilation/UnreferencedProperties/UnreferencedPropertiesFunction.cs @@ -19,6 +19,7 @@ using System.Reflection; using Serilog.Events; using Serilog.Expressions; +using Serilog.Expressions.Runtime; using Serilog.Parsing; using Serilog.Templates.Ast; @@ -74,11 +75,12 @@ public override bool TryResolveFunctionName(string name, [MaybeNullWhen(false)] // By convention, built-in functions accept and return nullable values. // ReSharper disable once ReturnTypeCanBeNotNullable - public static LogEventPropertyValue? Implementation(UnreferencedPropertiesFunction self, LogEvent logEvent) + public static LogEventPropertyValue? Implementation(UnreferencedPropertiesFunction self, LogEvent logEvent, LogEventPropertyValue? deep = null) { + var checkMessageTemplate = Coerce.IsTrue(deep); return new StructureValue(logEvent.Properties - .Where(kvp => !(self._referencedInTemplate.Contains(kvp.Key) || - TemplateContainsPropertyName(logEvent.MessageTemplate, kvp.Key))) + .Where(kvp => !self._referencedInTemplate.Contains(kvp.Key) && + (!checkMessageTemplate || !TemplateContainsPropertyName(logEvent.MessageTemplate, kvp.Key))) .Select(kvp => new LogEventProperty(kvp.Key, kvp.Value))); } diff --git a/test/Serilog.Expressions.Tests/Cases/template-evaluation-cases.asv b/test/Serilog.Expressions.Tests/Cases/template-evaluation-cases.asv index 3547b36..e8ea2f7 100644 --- a/test/Serilog.Expressions.Tests/Cases/template-evaluation-cases.asv +++ b/test/Serilog.Expressions.Tests/Cases/template-evaluation-cases.asv @@ -27,3 +27,6 @@ A{#if false}B{#else if true}C{#end} ⇶ AC {#if true}A{#each a in [1]}B{a}{#end}C{#end}D ⇶ AB1CD {#each a in []}{a}!{#else}none{#end} ⇶ none Culture-specific {42.34} ⇶ Culture-specific 42,34 +{rest()} ⇶ {"Name":"nblumhardt"} +{Name} {rest()} ⇶ nblumhardt {} +{rest(true)} ⇶ {} diff --git a/test/Serilog.Expressions.Tests/ExpressionCompilerTests.cs b/test/Serilog.Expressions.Tests/ExpressionCompilerTests.cs index ea4972b..235f43f 100644 --- a/test/Serilog.Expressions.Tests/ExpressionCompilerTests.cs +++ b/test/Serilog.Expressions.Tests/ExpressionCompilerTests.cs @@ -1,4 +1,5 @@ -using Serilog.Events; +using System; +using Serilog.Events; using System.Linq; using Serilog.Expressions.Tests.Support; using Xunit; @@ -134,6 +135,19 @@ public void InExaminesSequenceValues() Some.InformationEvent()); } + [Theory] + [InlineData("now(1)", "The function `now` accepts no arguments.")] + [InlineData("length()", "The function `length` accepts one argument, `value`.")] + [InlineData("length(1, 2)", "The function `length` accepts one argument, `value`.")] + [InlineData("round()", "The function `round` accepts two arguments, `number` and `places`.")] + [InlineData("substring()", "The function `substring` accepts arguments `string`, `startIndex`, and `length` (optional).")] + public void ReportsArityMismatches(string call, string expectedError) + { + // These will eventually be reported gracefully by `TryCompile()`... + var ex = Assert.Throws(() => SerilogExpression.Compile(call)); + Assert.Equal(expectedError, ex.Message); + } + static void AssertEvaluation(string expression, LogEvent match, params LogEvent[] noMatches) { var sink = new CollectingSink(); diff --git a/test/Serilog.Expressions.Tests/Templates/UnreferencedPropertiesFunctionTests.cs b/test/Serilog.Expressions.Tests/Templates/UnreferencedPropertiesFunctionTests.cs index 0f77836..1098dcb 100644 --- a/test/Serilog.Expressions.Tests/Templates/UnreferencedPropertiesFunctionTests.cs +++ b/test/Serilog.Expressions.Tests/Templates/UnreferencedPropertiesFunctionTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Serilog.Events; using Serilog.Parsing; using Serilog.Templates.Ast; @@ -20,7 +21,7 @@ public void UnreferencedPropertiesFunctionIsNamedRest() [Fact] public void UnreferencedPropertiesExcludeThoseInMessageAndTemplate() { - Assert.True(new TemplateParser().TryParse("{A + 1}{#if true}{B}{#end}", out var template, out _)); + Assert.True(new TemplateParser().TryParse("{@m}{A + 1}{#if true}{B}{#end}", out var template, out _)); var function = new UnreferencedPropertiesFunction(template!); @@ -37,11 +38,16 @@ public void UnreferencedPropertiesExcludeThoseInMessageAndTemplate() new LogEventProperty("D", new ScalarValue(null)), }); - var result = UnreferencedPropertiesFunction.Implementation(function, evt); + var deep = UnreferencedPropertiesFunction.Implementation(function, evt, new ScalarValue(true)); - var sv = Assert.IsType(result); + var sv = Assert.IsType(deep); var included = Assert.Single(sv.Properties); Assert.Equal("D", included!.Name); + + var shallow = UnreferencedPropertiesFunction.Implementation(function, evt); + sv = Assert.IsType(shallow); + Assert.Contains(sv.Properties, p => p.Name == "C"); + Assert.Contains(sv.Properties, p => p.Name == "D"); } } } \ No newline at end of file