Skip to content

Commit 99c9a95

Browse files
authored
Log QueryLayer and LINQ expression at Debug level (#1732)
* Log QueryLayer and Expression at Debug level * Replace debugger visualizer link in docs
1 parent b6b56b0 commit 99c9a95

File tree

12 files changed

+257
-7
lines changed

12 files changed

+257
-7
lines changed

docs/usage/faq.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ If there's no LINQ provider available, the example [here](https://github.com/jso
160160
which produces SQL and uses [Dapper](https://github.com/DapperLib/Dapper) for data access.
161161

162162
> [!TIP]
163-
> [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees!
163+
> [ReadableExpressions](https://github.com/agileobjects/ReadableExpressions) is very helpful in trying to debug LINQ expression trees!
164164
165165
#### I love JsonApiDotNetCore! How can I support the team?
166166
The best way to express your gratitude is by starring our repository.

package-versions.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<MicrosoftApiServerVersion>9.0.*</MicrosoftApiServerVersion>
2323
<NSwagApiClientVersion>14.4.*</NSwagApiClientVersion>
2424
<NewtonsoftJsonVersion>13.0.*</NewtonsoftJsonVersion>
25+
<ReadableExpressionsVersion>4.1.*</ReadableExpressionsVersion>
2526
<ScalarAspNetCoreVersion>2.3.*</ScalarAspNetCoreVersion>
2627
<SwashbuckleVersion>8.*-*</SwashbuckleVersion>
2728
<SystemTextJsonVersion>9.0.*</SystemTextJsonVersion>

src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
using System.Linq.Expressions;
12
using System.Reflection;
23
using System.Runtime.CompilerServices;
34
using System.Text;
45
using System.Text.Encodings.Web;
56
using System.Text.Json;
67
using System.Text.Json.Serialization;
78
using JsonApiDotNetCore.Configuration;
9+
using JsonApiDotNetCore.Queries;
10+
using JsonApiDotNetCore.Queries.QueryableBuilding;
811
using JsonApiDotNetCore.Resources;
912
using JsonApiDotNetCore.Resources.Annotations;
1013
using Microsoft.Extensions.Logging;
@@ -204,9 +207,45 @@ private static string SerializeObject(object? value)
204207
}
205208
}
206209

210+
public void LogDebug(QueryLayer queryLayer)
211+
{
212+
ArgumentNullException.ThrowIfNull(queryLayer);
213+
214+
LogQueryLayer(queryLayer);
215+
}
216+
217+
public void LogDebug(Expression expression)
218+
{
219+
ArgumentNullException.ThrowIfNull(expression);
220+
221+
if (_logger.IsEnabled(LogLevel.Debug))
222+
{
223+
string? text = ExpressionTreeFormatter.Instance.GetText(expression);
224+
225+
if (text != null)
226+
{
227+
LogExpression(text);
228+
}
229+
else
230+
{
231+
LogReadableExpressionsAssemblyUnavailable();
232+
}
233+
}
234+
}
235+
207236
[LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "Entering {MemberName}({ParameterValues})")]
208237
private partial void LogEnteringMemberWithParameters(string memberName, string parameterValues);
209238

210239
[LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "Entering {MemberName}()")]
211240
private partial void LogEnteringMember(string memberName);
241+
242+
[LoggerMessage(Level = LogLevel.Debug, Message = "QueryLayer: {queryLayer}")]
243+
private partial void LogQueryLayer(QueryLayer queryLayer);
244+
245+
[LoggerMessage(Level = LogLevel.Debug, SkipEnabledCheck = true, Message = "Expression tree: {expression}")]
246+
private partial void LogExpression(string expression);
247+
248+
[LoggerMessage(Level = LogLevel.Debug, SkipEnabledCheck = true,
249+
Message = "Failed to load assembly. To log expression trees, add a NuGet reference to 'AgileObjects.ReadableExpressions' in your project.")]
250+
private partial void LogReadableExpressionsAssemblyUnavailable();
212251
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Linq.Expressions;
2+
using System.Reflection;
3+
4+
namespace JsonApiDotNetCore.Queries.QueryableBuilding;
5+
6+
/// <summary>
7+
/// Converts a <see cref="Expression" /> to readable text, if the AgileObjects.ReadableExpressions NuGet package is referenced.
8+
/// </summary>
9+
internal sealed class ExpressionTreeFormatter
10+
{
11+
private static readonly Lazy<MethodInvoker?> LazyToReadableStringMethod = new(GetToReadableStringMethod, LazyThreadSafetyMode.ExecutionAndPublication);
12+
13+
public static ExpressionTreeFormatter Instance { get; } = new();
14+
15+
private ExpressionTreeFormatter()
16+
{
17+
}
18+
19+
private static MethodInvoker? GetToReadableStringMethod()
20+
{
21+
Assembly? assembly = TryLoadAssembly();
22+
Type? type = assembly?.GetType("AgileObjects.ReadableExpressions.ExpressionExtensions", false);
23+
MethodInfo? method = type?.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(method => method.Name == "ToReadableString");
24+
return method != null ? MethodInvoker.Create(method) : null;
25+
}
26+
27+
private static Assembly? TryLoadAssembly()
28+
{
29+
try
30+
{
31+
return Assembly.Load("AgileObjects.ReadableExpressions");
32+
}
33+
catch (Exception exception) when (exception is ArgumentException or IOException or BadImageFormatException)
34+
{
35+
}
36+
37+
return null;
38+
}
39+
40+
public string? GetText(Expression expression)
41+
{
42+
ArgumentNullException.ThrowIfNull(expression);
43+
44+
try
45+
{
46+
return LazyToReadableStringMethod.Value?.Invoke(null, expression, null) as string;
47+
}
48+
catch (Exception exception) when (exception is TargetException or InvalidOperationException or TargetParameterCountException or NotSupportedException)
49+
{
50+
return null;
51+
}
52+
}
53+
}

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ protected virtual IQueryable<TResource> ApplyQueryLayer(QueryLayer queryLayer)
122122

123123
ArgumentNullException.ThrowIfNull(queryLayer);
124124

125+
_traceWriter.LogDebug(queryLayer);
126+
125127
using (CodeTimingSessionManager.Current.Measure("Convert QueryLayer to System.Expression"))
126128
{
127129
IQueryable<TResource> source = GetAll();
@@ -151,6 +153,8 @@ protected virtual IQueryable<TResource> ApplyQueryLayer(QueryLayer queryLayer)
151153
var context = QueryableBuilderContext.CreateRoot(source, typeof(Queryable), _dbContext.Model, null);
152154
Expression expression = builder.ApplyQuery(queryLayer, context);
153155

156+
_traceWriter.LogDebug(expression);
157+
154158
using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable"))
155159
{
156160
return source.Provider.CreateQuery<TResource>(expression);

test/DiscoveryTests/DiscoveryTests.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
33
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
44
</PropertyGroup>
@@ -15,5 +15,6 @@
1515
<PackageReference Include="GitHubActionsTestLogger" Version="$(GitHubActionsTestLoggerVersion)" PrivateAssets="All" />
1616
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(TestSdkVersion)" />
1717
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Condition="'$(TargetFramework)' != 'net8.0'" Version="$(AspNetCoreVersion)" />
18+
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EntityFrameworkCoreVersion)" />
1819
</ItemGroup>
1920
</Project>

test/DiscoveryTests/LoggingTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using FluentAssertions;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Queries;
4+
using JsonApiDotNetCore.Repositories;
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.TestHost;
7+
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
using TestBuildingBlocks;
11+
using Xunit;
12+
13+
namespace DiscoveryTests;
14+
15+
public sealed class LoggingTests
16+
{
17+
[Fact]
18+
public async Task Logs_message_to_add_NuGet_reference()
19+
{
20+
// Arrange
21+
using var loggerProvider =
22+
new CapturingLoggerProvider((category, _) => category.StartsWith("JsonApiDotNetCore.Repositories", StringComparison.Ordinal));
23+
24+
WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions());
25+
builder.Logging.AddProvider(loggerProvider);
26+
builder.Logging.SetMinimumLevel(LogLevel.Debug);
27+
builder.Services.AddDbContext<TestDbContext>(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
28+
builder.Services.AddJsonApi<TestDbContext>();
29+
builder.WebHost.UseTestServer();
30+
await using WebApplication app = builder.Build();
31+
32+
var resourceGraph = app.Services.GetRequiredService<IResourceGraph>();
33+
ResourceType resourceType = resourceGraph.GetResourceType<PrivateResource>();
34+
35+
var repository = app.Services.GetRequiredService<IResourceRepository<PrivateResource, int>>();
36+
37+
// Act
38+
_ = await repository.GetAsync(new QueryLayer(resourceType), CancellationToken.None);
39+
40+
// Assert
41+
IReadOnlyList<string> logLines = loggerProvider.GetLines();
42+
43+
logLines.Should().Contain(
44+
"[DEBUG] Failed to load assembly. To log expression trees, add a NuGet reference to 'AgileObjects.ReadableExpressions' in your project.");
45+
}
46+
47+
private sealed class TestDbContext(DbContextOptions options)
48+
: DbContext(options)
49+
{
50+
public DbSet<PrivateResource> PrivateResources => Set<PrivateResource>();
51+
}
52+
}

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public AtomicTraceLoggingTests(IntegrationTestContext<TestableStartup<Operations
2222
testContext.ConfigureLogging(options =>
2323
{
2424
var loggerProvider = new CapturingLoggerProvider((category, level) =>
25-
level >= LogLevel.Trace && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal));
25+
level == LogLevel.Trace && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal));
2626

2727
options.AddProvider(loggerProvider);
2828
options.SetMinimumLevel(LogLevel.Trace);

test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ public abstract class Fruit : Identifiable<long>
1010
{
1111
[Attr]
1212
public abstract string Color { get; }
13+
14+
[Attr]
15+
public double WeightInKilograms { get; set; }
1316
}

test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ internal sealed class LoggingFakers
1515

1616
private readonly Lazy<Faker<Banana>> _lazyBananaFaker = new(() => new Faker<Banana>()
1717
.MakeDeterministic()
18+
.RuleFor(banana => banana.WeightInKilograms, faker => faker.Random.Double(.2, .3))
1819
.RuleFor(banana => banana.LengthInCentimeters, faker => faker.Random.Double(10, 25)));
1920

2021
private readonly Lazy<Faker<Peach>> _lazyPeachFaker = new(() => new Faker<Peach>()
2122
.MakeDeterministic()
23+
.RuleFor(peach => peach.WeightInKilograms, faker => faker.Random.Double(.2, .3))
2224
.RuleFor(peach => peach.DiameterInCentimeters, faker => faker.Random.Double(6, 7.5)));
2325

2426
public Faker<AuditEntry> AuditEntry => _lazyAuditEntryFaker.Value;

0 commit comments

Comments
 (0)