77using System . Data ;
88using System . Data . SqlTypes ;
99using System . Diagnostics . Tracing ;
10- using System . Globalization ;
1110using System . IO ;
1211using System . Linq ;
1312using System . Net ;
1716using System . Security ;
1817using System . Security . Principal ;
1918using System . Text ;
19+ using System . Text . RegularExpressions ;
2020using System . Threading ;
2121using System . Threading . Tasks ;
2222using Azure . Core ;
2323using Azure . Identity ;
2424using Microsoft . Data . SqlClient . TestUtilities ;
2525using Microsoft . Identity . Client ;
2626using Xunit ;
27+ using Xunit . Abstractions ;
2728
2829namespace Microsoft . Data . SqlClient . ManualTesting . Tests
2930{
@@ -102,7 +103,7 @@ public static bool IsAzureSynapse
102103 {
103104 if ( ! string . IsNullOrEmpty ( TCPConnectionString ) )
104105 {
105- s_sqlServerEngineEdition ??= GetSqlServerProperty ( TCPConnectionString , " EngineEdition" ) ;
106+ s_sqlServerEngineEdition ??= GetSqlServerProperty ( TCPConnectionString , ServerProperty . EngineEdition ) ;
106107 }
107108 _ = int . TryParse ( s_sqlServerEngineEdition , out int engineEditon ) ;
108109 return engineEditon == 6 ;
@@ -124,7 +125,7 @@ public static string SQLServerVersion
124125 {
125126 if ( ! string . IsNullOrEmpty ( TCPConnectionString ) )
126127 {
127- s_sQLServerVersion ??= GetSqlServerProperty ( TCPConnectionString , " ProductMajorVersion" ) ;
128+ s_sQLServerVersion ??= GetSqlServerProperty ( TCPConnectionString , ServerProperty . ProductMajorVersion ) ;
128129 }
129130 return s_sQLServerVersion ;
130131 }
@@ -238,7 +239,9 @@ public static IEnumerable<string> GetConnectionStrings(bool withEnclave)
238239 yield return TCPConnectionString ;
239240 }
240241 // Named Pipes are not supported on Unix platform and for Azure DB
241- if ( Environment . OSVersion . Platform != PlatformID . Unix && IsNotAzureServer ( ) && ! string . IsNullOrEmpty ( NPConnectionString ) )
242+ if ( Environment . OSVersion . Platform != PlatformID . Unix &&
243+ IsNotAzureServer ( ) &&
244+ ! string . IsNullOrEmpty ( NPConnectionString ) )
242245 {
243246 yield return NPConnectionString ;
244247 }
@@ -287,29 +290,99 @@ private static Task<string> AcquireTokenAsync(string authorityURL, string userID
287290
288291 public static bool IsKerberosTest => ! string . IsNullOrEmpty ( KerberosDomainUser ) && ! string . IsNullOrEmpty ( KerberosDomainPassword ) ;
289292
290- public static string GetSqlServerProperty ( string connectionString , string propertyName )
293+ #nullable enable
294+
295+ /// <summary>
296+ /// Returns the current test name as:
297+ ///
298+ /// ClassName.MethodName
299+ ///
300+ /// xUnit v2 doesn't provide access to a test context, so we use
301+ /// reflection into the ITestOutputHelper to get the test name.
302+ /// </summary>
303+ ///
304+ /// <param name="outputHelper">
305+ /// The output helper instance for the currently running test.
306+ /// </param>
307+ ///
308+ /// <returns>The current test name.</returns>
309+ public static string CurrentTestName ( ITestOutputHelper outputHelper )
310+ {
311+ // Reflect our way to the ITest instance.
312+ var type = outputHelper . GetType ( ) ;
313+ Assert . NotNull ( type ) ;
314+ var testMember = type . GetField ( "test" , BindingFlags . Instance | BindingFlags . NonPublic ) ;
315+ Assert . NotNull ( testMember ) ;
316+ var test = testMember . GetValue ( outputHelper ) as ITest ;
317+ Assert . NotNull ( test ) ;
318+
319+ // The DisplayName is in the format:
320+ //
321+ // Namespace.ClassName.MethodName(args)
322+ //
323+ // We only want the ClassName.MethodName portion.
324+ //
325+ Match match = TestNameRegex . Match ( test . DisplayName ) ;
326+ Assert . True ( match . Success ) ;
327+ // There should be 2 groups: the overall match, and the capture
328+ // group.
329+ Assert . Equal ( 2 , match . Groups . Count ) ;
330+
331+ // The portion we want is in the capture group.
332+ return match . Groups [ 1 ] . Value ;
333+ }
334+
335+ private static readonly Regex TestNameRegex = new (
336+ // Capture the ClassName.MethodName portion, which may terminate
337+ // the name, or have (args...) appended.
338+ @"\.((?:[^.]+)\.(?:[^.\(]+))(?:\(.*\))?$" ,
339+ RegexOptions . Compiled ) ;
340+
341+ /// <summary>
342+ /// SQL Server properties we can query.
343+ ///
344+ /// GOTCHA: The enum member names must match the property names
345+ /// queryable via T-SQL SERVERPROPERTY(). See:
346+ ///
347+ /// https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql
348+ /// </summary>
349+ public enum ServerProperty
350+ {
351+ ProductMajorVersion ,
352+ EngineEdition
353+ }
354+
355+ public static string GetSqlServerProperty ( string connectionString , ServerProperty property )
291356 {
292- string propertyValue = string . Empty ;
293357 using SqlConnection conn = new ( connectionString ) ;
294358 conn . Open ( ) ;
295- SqlCommand command = conn . CreateCommand ( ) ;
296- command . CommandText = $ "SELECT SERVERProperty('{ propertyName } ')";
297- SqlDataReader reader = command . ExecuteReader ( ) ;
298- if ( reader . Read ( ) )
359+ return GetSqlServerProperty ( conn , property ) ;
360+ }
361+
362+ public static string GetSqlServerProperty ( SqlConnection connection , ServerProperty property )
363+ {
364+ using SqlCommand command = connection . CreateCommand ( ) ;
365+ command . CommandText = $ "SELECT SERVERProperty('{ property } ')";
366+ using SqlDataReader reader = command . ExecuteReader ( ) ;
367+
368+ Assert . True ( reader . Read ( ) ) ;
369+
370+ switch ( property )
299371 {
300- switch ( propertyName )
301- {
302- case "EngineEdition" :
303- propertyValue = reader . GetInt32 ( 0 ) . ToString ( ) ;
304- break ;
305- case " ProductMajorVersion" :
306- propertyValue = reader . GetString ( 0 ) ;
307- break ;
308- }
372+ case ServerProperty . EngineEdition :
373+ // EngineEdition is returned as an int.
374+ return reader . GetInt32 ( 0 ) . ToString ( ) ;
375+ case ServerProperty . ProductMajorVersion :
376+ default :
377+ // ProductMajorVersion is returned as a string.
378+ //
379+ // Assume any unknown property is also a string.
380+ return reader . GetString ( 0 ) ;
309381 }
310- return propertyValue ;
311382 }
312383
384+ #nullable disable
385+
313386 public static bool GetSQLServerStatusOnTDS8 ( string connectionString )
314387 {
315388 bool isTDS8Supported = false ;
@@ -1168,8 +1241,200 @@ protected override void OnEventWritten(EventWrittenEventArgs eventData)
11681241 {
11691242 IDs . Add ( eventData . EventId ) ;
11701243 EventData . Add ( eventData ) ;
1244+ OnMatchingEventWritten ( eventData ) ;
11711245 }
11721246 }
1247+
1248+ protected virtual void OnMatchingEventWritten ( EventWrittenEventArgs eventData )
1249+ {
1250+ }
1251+ }
1252+
1253+ #nullable enable
1254+
1255+ public class XEventScope : IDisposable
1256+ {
1257+ #region Private Fields
1258+
1259+ // Maximum dispatch latency for XEvents, in seconds.
1260+ private const int MaxDispatchLatencySeconds = 5 ;
1261+
1262+ // The connection to use for all operations.
1263+ private readonly SqlConnection _connection ;
1264+
1265+ // True if connected to an Azure SQL instance.
1266+ private readonly bool _isAzureSql ;
1267+
1268+ // True if connected to a non-Azure SQL Server 2025 (version 17) or
1269+ // higher.
1270+ private readonly bool _isVersion17OrHigher ;
1271+
1272+ // Duration for the XEvent session, in minutes.
1273+ private readonly ushort _durationInMinutes ;
1274+
1275+ #endregion
1276+
1277+ #region Properties
1278+
1279+ /// <summary>
1280+ /// The name of the XEvent session, derived from the session name
1281+ /// provided at construction time, with a unique suffix appended.
1282+ /// </summary>
1283+ public string SessionName { get ; }
1284+
1285+ #endregion
1286+
1287+ #region Construction
1288+
1289+ /// <summary>
1290+ /// Construct with the specified parameters.
1291+ ///
1292+ /// This will use the connection to query the server properties and
1293+ /// setup and start the XEvent session.
1294+ /// </summary>
1295+ /// <param name="sessionName">The base name of the session.</param>
1296+ /// <param name="connection">The SQL connection to use. (Must already be open.)</param>
1297+ /// <param name="eventSpecification">The event specification T-SQL string.</param>
1298+ /// <param name="targetSpecification">The target specification T-SQL string.</param>
1299+ /// <param name="durationInMinutes">The duration of the session in minutes.</param>
1300+ public XEventScope (
1301+ string sessionName ,
1302+ // The connection must already be open.
1303+ SqlConnection connection ,
1304+ string eventSpecification ,
1305+ string targetSpecification ,
1306+ ushort durationInMinutes = 5 )
1307+ {
1308+ SessionName = GenerateRandomCharacters ( sessionName ) ;
1309+
1310+ _connection = connection ;
1311+ Assert . Equal ( ConnectionState . Open , _connection . State ) ;
1312+
1313+ _durationInMinutes = durationInMinutes ;
1314+
1315+ // EngineEdition 5 indicates Azure SQL.
1316+ _isAzureSql = GetSqlServerProperty ( connection , ServerProperty . EngineEdition ) == "5" ;
1317+
1318+ // Determine if we're connected to a SQL Server instance version
1319+ // 17 or higher.
1320+ if ( ! _isAzureSql )
1321+ {
1322+ int majorVersion ;
1323+ Assert . True (
1324+ int . TryParse (
1325+ GetSqlServerProperty ( connection , ServerProperty . ProductMajorVersion ) ,
1326+ out majorVersion ) ) ;
1327+ _isVersion17OrHigher = majorVersion >= 17 ;
1328+ }
1329+
1330+ // Setup and start the XEvent session.
1331+ string sessionLocation = _isAzureSql ? "DATABASE" : "SERVER" ;
1332+
1333+ // Both Azure SQL and SQL Server 2025+ support setting a maximum
1334+ // duration for the XEvent session.
1335+ string duration =
1336+ _isAzureSql || _isVersion17OrHigher
1337+ ? $ "MAX_DURATION={ _durationInMinutes } MINUTES,"
1338+ : string . Empty ;
1339+
1340+ string xEventCreateAndStartCommandText =
1341+ $@ "CREATE EVENT SESSION [{ SessionName } ] ON { sessionLocation }
1342+ { eventSpecification }
1343+ { targetSpecification }
1344+ WITH (
1345+ { duration }
1346+ MAX_MEMORY=16 MB,
1347+ EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS,
1348+ MAX_DISPATCH_LATENCY={ MaxDispatchLatencySeconds } SECONDS,
1349+ MAX_EVENT_SIZE=0 KB,
1350+ MEMORY_PARTITION_MODE=NONE,
1351+ TRACK_CAUSALITY=ON,
1352+ STARTUP_STATE=OFF)
1353+
1354+ ALTER EVENT SESSION [{ SessionName } ] ON { sessionLocation } STATE = START " ;
1355+
1356+ using SqlCommand createXEventSession = new SqlCommand ( xEventCreateAndStartCommandText , _connection ) ;
1357+ createXEventSession . ExecuteNonQuery ( ) ;
1358+ }
1359+
1360+ /// <summary>
1361+ /// Disposal stops and drops the XEvent session.
1362+ /// </summary>
1363+ /// <remarks>
1364+ /// Disposal isn't perfect - tests can abort without cleaning up the
1365+ /// events they have created. For Azure SQL targets that outlive the
1366+ /// test pipelines, it is beneficial to periodically log into the
1367+ /// database and drop old XEvent sessions using T-SQL similar to
1368+ /// this:
1369+ ///
1370+ /// DECLARE @sql NVARCHAR(MAX) = N'';
1371+ ///
1372+ /// -- Identify inactive (stopped) event sessions and generate DROP commands
1373+ /// SELECT @sql += N'DROP EVENT SESSION [' + name + N'] ON SERVER;' + CHAR(13) + CHAR(10)
1374+ /// FROM sys.server_event_sessions
1375+ /// WHERE running = 0; -- Filter for sessions that are not running (inactive)
1376+ ///
1377+ /// -- Print the generated commands for review (optional, but recommended)
1378+ /// PRINT @sql;
1379+ ///
1380+ /// -- Execute the generated commands
1381+ /// EXEC sys.sp_executesql @sql;
1382+ /// </remarks>
1383+ public void Dispose ( )
1384+ {
1385+ string dropXEventSessionCommand = _isAzureSql
1386+ // We choose the sys.(database|server)_event_sessions views
1387+ // here to ensure we find sessions that may not be running.
1388+ ? $ "IF EXISTS (select * from sys.database_event_sessions where name ='{ SessionName } ')" +
1389+ $ " DROP EVENT SESSION [{ SessionName } ] ON DATABASE"
1390+ : $ "IF EXISTS (select * from sys.server_event_sessions where name ='{ SessionName } ')" +
1391+ $ " DROP EVENT SESSION [{ SessionName } ] ON SERVER";
1392+
1393+ using SqlCommand command = new SqlCommand ( dropXEventSessionCommand , _connection ) ;
1394+ command . ExecuteNonQuery ( ) ;
1395+ }
1396+
1397+ #endregion
1398+
1399+ #region Public Methods
1400+
1401+ /// <summary>
1402+ /// Query the XEvent session for its collected events, returning
1403+ /// them as an XML document.
1404+ ///
1405+ /// This always blocks the thread for MaxDispatchLatencySeconds to
1406+ /// ensure that all events have been flushed into the ring buffer.
1407+ /// </summary>
1408+ public System . Xml . XmlDocument GetEvents ( )
1409+ {
1410+ string xEventQuery = _isAzureSql
1411+ ? $@ "SELECT xet.target_data
1412+ FROM sys.dm_xe_database_session_targets AS xet
1413+ INNER JOIN sys.dm_xe_database_sessions AS xe
1414+ ON (xe.address = xet.event_session_address)
1415+ WHERE xe.name = '{ SessionName } '"
1416+ : $@ "SELECT xet.target_data
1417+ FROM sys.dm_xe_session_targets AS xet
1418+ INNER JOIN sys.dm_xe_sessions AS xe
1419+ ON (xe.address = xet.event_session_address)
1420+ WHERE xe.name = '{ SessionName } '" ;
1421+
1422+ using SqlCommand command = new SqlCommand ( xEventQuery , _connection ) ;
1423+
1424+ // Wait for maximum dispatch latency to ensure all events
1425+ // have been flushed to the ring buffer.
1426+ Thread . Sleep ( MaxDispatchLatencySeconds * 1000 ) ;
1427+
1428+ string ? targetData = command . ExecuteScalar ( ) as string ;
1429+ Assert . NotNull ( targetData ) ;
1430+
1431+ System . Xml . XmlDocument xmlDocument = new System . Xml . XmlDocument ( ) ;
1432+
1433+ xmlDocument . LoadXml ( targetData ) ;
1434+ return xmlDocument ;
1435+ }
1436+
1437+ #endregion
11731438 }
11741439
11751440 /// <summary>
@@ -1198,4 +1463,6 @@ public static string GetMachineFQDN(string hostname)
11981463 return fqdn . ToString ( ) ;
11991464 }
12001465 }
1466+
1467+ #nullable disable
12011468}
0 commit comments