Skip to content

Commit 55e636a

Browse files
committed
Add MessageInterceptor and connection closing hooks
1 parent 0451ad1 commit 55e636a

7 files changed

Lines changed: 182 additions & 5 deletions

File tree

.github/dependabot.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ updates:
1717
open-pull-requests-limit: 10
1818
ignore:
1919
- dependency-name: "Microsoft.CodeAnalysis.CSharp"
20-
- dependency-name: FluentAssertions
21-
versions: [">=8.0.0"]
2220
groups:
2321
Azure:
2422
patterns:

src/FluentCommand.SqlServer/DataConfigurationBuilderExtensions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,18 @@ public static DataConfigurationBuilder UseSqlServer(this DataConfigurationBuilde
2323

2424
return builder;
2525
}
26+
27+
/// <summary>
28+
/// Configures the <see cref="DataConfigurationBuilder"/> to capture SQL Server informational messages such as PRINT output.
29+
/// </summary>
30+
/// <param name="builder">The data configuration builder to configure.</param>
31+
/// <returns>
32+
/// The same <see cref="DataConfigurationBuilder"/> instance so that multiple calls can be chained.
33+
/// </returns>
34+
public static DataConfigurationBuilder CaptureMessages(this DataConfigurationBuilder builder)
35+
{
36+
builder.AddInterceptor<MessageInterceptor>();
37+
38+
return builder;
39+
}
2640
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Data.Common;
2+
3+
using Microsoft.Data.SqlClient;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace FluentCommand;
7+
8+
/// <summary>
9+
/// Captures SQL Server informational messages such as PRINT output.
10+
/// </summary>
11+
/// <seealso cref="IDataConnectionInterceptor" />
12+
public class MessageInterceptor : IDataConnectionInterceptor
13+
{
14+
/// <summary>
15+
/// Initializes a new instance of the <see cref="MessageInterceptor"/> class.
16+
/// </summary>
17+
/// <param name="logger">The logger.</param>
18+
public MessageInterceptor(ILogger<MessageInterceptor> logger)
19+
{
20+
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
21+
}
22+
23+
/// <summary>
24+
/// Gets the logger.
25+
/// </summary>
26+
protected ILogger Logger { get; }
27+
28+
/// <inheritdoc />
29+
public virtual void ConnectionOpened(DbConnection connection, IDataSession session)
30+
{
31+
if (connection is SqlConnection sqlConnection)
32+
sqlConnection.InfoMessage += OnInfoMessage;
33+
}
34+
35+
/// <inheritdoc />
36+
public virtual Task ConnectionOpenedAsync(DbConnection connection, IDataSession session, CancellationToken cancellationToken = default)
37+
{
38+
ConnectionOpened(connection, session);
39+
return Task.CompletedTask;
40+
}
41+
42+
/// <inheritdoc />
43+
public virtual void ConnectionClosing(DbConnection connection, IDataSession session)
44+
{
45+
if (connection is SqlConnection sqlConnection)
46+
sqlConnection.InfoMessage -= OnInfoMessage;
47+
}
48+
49+
/// <inheritdoc />
50+
public virtual Task ConnectionClosingAsync(DbConnection connection, IDataSession session, CancellationToken cancellationToken = default)
51+
{
52+
ConnectionClosing(connection, session);
53+
return Task.CompletedTask;
54+
}
55+
56+
/// <summary>
57+
/// Handles SQL Server informational messages.
58+
/// </summary>
59+
/// <param name="sender">The event source.</param>
60+
/// <param name="e">The SQL Server message event data.</param>
61+
protected virtual void OnInfoMessage(object sender, SqlInfoMessageEventArgs e)
62+
{
63+
if (e is null)
64+
return;
65+
66+
Logger.LogInformation("SQL Server message: {Message}", e.Message);
67+
}
68+
}

src/FluentCommand/DataSession.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ public void ReleaseConnection()
286286

287287
// When no operation is using the connection and the context had opened the connection
288288
// the connection can be closed
289+
foreach (var interceptor in _connectionInterceptors)
290+
interceptor.ConnectionClosing(Connection, this);
291+
289292
Connection.Close();
290293
_openedConnection = false;
291294
}
@@ -309,6 +312,9 @@ public async Task ReleaseConnectionAsync()
309312

310313
// When no operation is using the connection and the context had opened the connection
311314
// the connection can be closed
315+
foreach (var interceptor in _connectionInterceptors)
316+
await interceptor.ConnectionClosingAsync(Connection, this).ConfigureAwait(false);
317+
312318
await Connection.CloseAsync().ConfigureAwait(false);
313319
_openedConnection = false;
314320
}
@@ -324,6 +330,14 @@ protected override async ValueTask DisposeResourcesAsync()
324330
if (Transaction is not null)
325331
await Transaction.DisposeAsync().ConfigureAwait(false);
326332

333+
if (_openedConnection)
334+
{
335+
foreach (var interceptor in _connectionInterceptors)
336+
await interceptor.ConnectionClosingAsync(Connection, this).ConfigureAwait(false);
337+
338+
_openedConnection = false;
339+
}
340+
327341
await Connection.DisposeAsync().ConfigureAwait(false);
328342
}
329343
#endif
@@ -337,6 +351,15 @@ protected override void DisposeManagedResources()
337351
return;
338352

339353
Transaction?.Dispose();
354+
355+
if (_openedConnection)
356+
{
357+
foreach (var interceptor in _connectionInterceptors)
358+
interceptor.ConnectionClosing(Connection, this);
359+
360+
_openedConnection = false;
361+
}
362+
340363
Connection.Dispose();
341364
}
342365

src/FluentCommand/FluentCommand.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
<PackageReference Include="System.ComponentModel.Annotations" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
1515
</ItemGroup>
1616
<ItemGroup>
17-
<None Include="..\FluentCommand.Generators\bin\$(Configuration)\netstandard2.0\FluentCommand.Generators.dll" PackagePath="analyzers/dotnet/roslyn4.8/cs" Pack="true" Visible="false" />
17+
<None Include="..\FluentCommand.Generators\bin\$(Configuration)\netstandard2.0\FluentCommand.Generators.dll" PackagePath="analyzers/dotnet/cs" Pack="true" Visible="false" />
1818
</ItemGroup>
1919
</Project>

src/FluentCommand/IDataConnectionInterceptor.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,27 @@ public interface IDataConnectionInterceptor : IDataInterceptor
2525
/// <param name="session">The <see cref="IDataSession"/> that opened the connection.</param>
2626
/// <param name="cancellationToken">The cancellation instruction.</param>
2727
/// <returns>A task representing the asynchronous operation.</returns>
28-
Task ConnectionOpenedAsync(DbConnection connection, IDataSession session, CancellationToken cancellationToken = default);
28+
Task ConnectionOpenedAsync(
29+
DbConnection connection,
30+
IDataSession session,
31+
CancellationToken cancellationToken = default);
32+
33+
/// <summary>
34+
/// Called immediately before a database connection is closed.
35+
/// </summary>
36+
/// <param name="connection">The <see cref="DbConnection"/> that is about to be closed.</param>
37+
/// <param name="session">The <see cref="IDataSession"/> that opened the connection.</param>
38+
void ConnectionClosing(DbConnection connection, IDataSession session);
39+
40+
/// <summary>
41+
/// Called immediately before a database connection is closed asynchronously.
42+
/// </summary>
43+
/// <param name="connection">The <see cref="DbConnection"/> that is about to be closed.</param>
44+
/// <param name="session">The <see cref="IDataSession"/> that opened the connection.</param>
45+
/// <param name="cancellationToken">The cancellation instruction.</param>
46+
/// <returns>A task representing the asynchronous operation.</returns>
47+
Task ConnectionClosingAsync(
48+
DbConnection connection,
49+
IDataSession session,
50+
CancellationToken cancellationToken = default);
2951
}

test/FluentCommand.SqlServer.Tests/DataInterceptorTests.cs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Data.Common;
22

33
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Logging;
45

56
namespace FluentCommand.SqlServer.Tests;
67

@@ -23,6 +24,8 @@ public void WhenConnectionOpened_ConnectionInterceptorCalledOnce()
2324

2425
interceptor.ConnectionOpenedCount.Should().Be(1);
2526
interceptor.ConnectionOpenedAsyncCount.Should().Be(0);
27+
interceptor.ConnectionClosingCount.Should().Be(1);
28+
interceptor.ConnectionClosingAsyncCount.Should().Be(0);
2629
}
2730

2831
[Fact]
@@ -34,10 +37,12 @@ public async Task WhenConnectionOpenedAsync_ConnectionInterceptorCalledOnce()
3437
await using var session = new DataSession(config.CreateConnection(), interceptors: [interceptor]);
3538

3639
await session.EnsureConnectionAsync(TestCancellation);
37-
session.ReleaseConnection();
40+
await session.ReleaseConnectionAsync();
3841

3942
interceptor.ConnectionOpenedAsyncCount.Should().Be(1);
4043
interceptor.ConnectionOpenedCount.Should().Be(0);
44+
interceptor.ConnectionClosingAsyncCount.Should().Be(1);
45+
interceptor.ConnectionClosingCount.Should().Be(0);
4146
}
4247

4348
[Fact]
@@ -54,6 +59,7 @@ public void WhenEnsureConnectionCalledTwice_ConnectionInterceptorCalledOnce()
5459
session.ReleaseConnection();
5560

5661
interceptor.ConnectionOpenedCount.Should().Be(1);
62+
interceptor.ConnectionClosingCount.Should().Be(1);
5763
}
5864

5965
[Fact]
@@ -144,11 +150,27 @@ public void WhenCreatedWithNoInterceptors_SessionExposesEmptyList()
144150
session.Interceptors.Should().BeEmpty();
145151
}
146152

153+
[Fact]
154+
public void WhenSqlPrintExecuted_PrintMessageIsLogged()
155+
{
156+
var config = Services.GetRequiredService<IDataConfiguration>();
157+
var logger = new TrackingLogger<MessageInterceptor>();
158+
var interceptor = new MessageInterceptor(logger);
159+
160+
using var session = new DataSession(config.CreateConnection(), interceptors: [interceptor]);
161+
162+
session.Sql("PRINT 'fluent command print message'; SELECT 1").QueryValue<int>().Should().Be(1);
163+
164+
logger.Messages.Should().Contain(m => m.Contains("fluent command print message", StringComparison.Ordinal));
165+
}
166+
147167

148168
private sealed class TrackingConnectionInterceptor : IDataConnectionInterceptor
149169
{
150170
public int ConnectionOpenedCount { get; private set; }
151171
public int ConnectionOpenedAsyncCount { get; private set; }
172+
public int ConnectionClosingCount { get; private set; }
173+
public int ConnectionClosingAsyncCount { get; private set; }
152174

153175
public void ConnectionOpened(DbConnection connection, IDataSession session)
154176
=> ConnectionOpenedCount++;
@@ -158,6 +180,15 @@ public Task ConnectionOpenedAsync(DbConnection connection, IDataSession session,
158180
ConnectionOpenedAsyncCount++;
159181
return Task.CompletedTask;
160182
}
183+
184+
public void ConnectionClosing(DbConnection connection, IDataSession session)
185+
=> ConnectionClosingCount++;
186+
187+
public Task ConnectionClosingAsync(DbConnection connection, IDataSession session, CancellationToken cancellationToken = default)
188+
{
189+
ConnectionClosingAsyncCount++;
190+
return Task.CompletedTask;
191+
}
161192
}
162193

163194
private sealed class TrackingCommandInterceptor : IDataCommandInterceptor
@@ -174,4 +205,25 @@ public Task CommandExecutingAsync(DbCommand command, IDataSession session, Cance
174205
return Task.CompletedTask;
175206
}
176207
}
208+
209+
private sealed class TrackingLogger<T> : ILogger<T>
210+
{
211+
public List<string> Messages { get; } = [];
212+
213+
public IDisposable? BeginScope<TState>(TState state)
214+
where TState : notnull
215+
=> null;
216+
217+
public bool IsEnabled(LogLevel logLevel) => true;
218+
219+
public void Log<TState>(
220+
LogLevel logLevel,
221+
EventId eventId,
222+
TState state,
223+
Exception? exception,
224+
Func<TState, Exception?, string> formatter)
225+
{
226+
Messages.Add(formatter(state, exception));
227+
}
228+
}
177229
}

0 commit comments

Comments
 (0)