using System.Data; using System.Data.Common; using System.Globalization; using System.Text.Json.Nodes; using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; /// /// The Sql driver against a real SQL Server, over the Microsoft.Data.SqlClient /// provider it actually ships. Everything else in this driver's test estate runs on SQLite, which /// means three things have never been exercised anywhere until this suite: the shipped ADO.NET /// provider, T-SQL's own syntax (bracket-quoted two-part names, SELECT TOP 1), and the /// INFORMATION_SCHEMA catalog SQL that SqlServerDialect carries but that no offline test /// can reach — the SQLite dialect answers those questions with pragma_table_info instead. /// Env-gated; skipping is the normal outcome. See for /// the gate. With it unset nothing here opens a socket, so the suite is instant and green offline — /// which is the point: a live test that goes red on a laptop teaches people to ignore red. /// Out of scope, deliberately: the frozen-database / BadTimeout gate. That needs a /// docker pause-able container this suite must never own, because the server it points at is /// the rig's shared central SQL Server. It is a separate task with its own dedicated mssql /// container. /// [Collection(SqlPollServerCollection.Name)] public sealed class SqlServerReadTests { private readonly SqlPollServerFixture _sql; /// Initializes a new instance of the class. /// The seeded live-server fixture (collection-scoped). public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql; // ---- lifecycle ---- /// /// Initialize's liveness check (SELECT 1 over a real connection) succeeds and the driver /// reports Healthy with the endpoint described credential-free. /// [Fact] public async Task InitializeAsync_realSqlServer_reportsHealthyAndDescribesTheEndpoint() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Healthy); health.LastSuccessfulRead.ShouldNotBeNull(); // The endpoint description is what every log line and the Admin UI show. It must name the // database and must not carry the password that reached the provider. driver.Endpoint.ShouldContain(SqlPollServerFixture.FixtureDatabase); driver.Endpoint.ShouldNotContain("Password"); driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); } // ---- key-value model ---- /// The plan's headline case: a seeded key-value row round-trips as a Good snapshot. [Fact] public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken); var snapshot = snapshots.ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentValue); // datetime2 comes back Unspecified; the reader stamps it UTC rather than reinterpreting it // through the host's zone, which is the behaviour this asserts against a real column type. snapshot.SourceTimestampUtc.ShouldBe(SqlPollServerFixture.PresentKeyTimestampUtc); } /// /// Two keys on the same table fold into ONE query (… WHERE [tag_name] IN (@k0, @k1)) and are /// sliced back to the right tags. Against a real server this also proves the parameter binding and /// the key-column stringification survive a genuine nvarchar key. /// [Fact] public async Task ReadAsync_realSqlServer_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly() { SkipUnlessLive(); await using var driver = await StartAsync( KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); var snapshots = await driver.ReadAsync( ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(2); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentValue); snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.SecondPresentValue); } /// /// A present row whose value cell is a real T-SQL NULL is Uncertain, not Bad and not /// BadNoData — the row WAS read. /// [Fact] public async Task ReadAsync_realSqlServer_nullValueCellIsUncertain() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Uncertain); snapshot.Value.ShouldBeNull(); // The row exists, so its timestamp is still readable — that is exactly what distinguishes this // from the absent-key case below. snapshot.SourceTimestampUtc.ShouldNotBeNull(); } /// nullIsBad re-codes the same NULL cell as Bad, and only that cell. [Fact] public async Task ReadAsync_realSqlServer_nullValueCellIsBadWhenNullIsBadIsSet() { SkipUnlessLive(); await using var driver = await StartAsync( nullIsBad: true, KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Bad); } /// A key the table has no row for is BadNoData with no source timestamp. [Fact] public async Task ReadAsync_realSqlServer_absentKeyIsBadNoData() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Missing", SqlPollServerFixture.AbsentKey)); var snapshot = (await driver.ReadAsync(["Sql/Line1/Missing"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNoData); snapshot.SourceTimestampUtc.ShouldBeNull(); } // ---- wide-row model ---- /// /// Two columns of one wide row, selected by a whereColumn/whereValue pair, come back from a /// single query — including the bound selector value coercing from authored text to a real /// int column server-side. /// [Fact] public async Task ReadAsync_realSqlServer_wideRowSelectorReadsEveryColumnFromOneRow() { SkipUnlessLive(); await using var driver = await StartAsync( WideRowWhere("Sql/Station7/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation), WideRowWhere("Sql/Station7/Pressure", "pressure", SqlPollServerFixture.PresentStation), WideRowWhere("Sql/Station404/OvenTemp", "oven_temp", SqlPollServerFixture.AbsentStation)); var snapshots = await driver.ReadAsync( ["Sql/Station7/OvenTemp", "Sql/Station7/Pressure", "Sql/Station404/OvenTemp"], TestContext.Current.CancellationToken); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.PresentStationPressure); // A selector matching no row is BadNoData, not an error — and it does not disturb its siblings. snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); } /// /// The topByTimestamp selector resolves to the newest row. /// This is the T-SQL-specific leg. The planner emits the row limit through the /// dialect's SingleRowLimitPrefixSELECT TOP 1 … for T-SQL, where every offline /// test exercises SQLite's trailing LIMIT 1 instead. The seed makes the newest row not the /// first-inserted one, so losing either the TOP 1 or the ORDER BY … DESC yields a /// different, wrong value rather than a coincidentally-right one. /// [Fact] public async Task ReadAsync_realSqlServer_topByTimestampSelectsTheNewestRow() { SkipUnlessLive(); await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp")); var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.NewestStationOvenTemp); } /// A view reads exactly like the table it wraps — the shape operators are told to author. [Fact] public async Task ReadAsync_realSqlServer_readsThroughAView() { SkipUnlessLive(); await using var driver = await StartAsync( WideRowWhere( "Sql/View/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation, table: SqlPollServerFixture.WideRowView)); var snapshot = (await driver.ReadAsync(["Sql/View/OvenTemp"], TestContext.Current.CancellationToken)) .ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); } // ---- type mapping against real T-SQL column types ---- /// /// With no authored type, each tag's driver type is inferred from the provider's own /// GetDataTypeName through SqlServerDialect.MapColumnType. This is the only test in /// the estate where that map is checked against the strings SQL Server actually returns — /// everywhere else it is fed a hand-written list, i.e. checked against itself. /// [Fact] public async Task ReadAsync_realSqlServer_infersDriverTypesFromRealTsqlColumnTypes() { SkipUnlessLive(); await using var driver = await StartAsync( TypeProbe("Sql/Probe/Bit", "bit_col"), TypeProbe("Sql/Probe/Int", "int_col"), TypeProbe("Sql/Probe/BigInt", "bigint_col"), TypeProbe("Sql/Probe/Real", "real_col"), TypeProbe("Sql/Probe/Float", "float_col"), TypeProbe("Sql/Probe/Decimal", "decimal_col"), TypeProbe("Sql/Probe/NVarChar", "nvarchar_col"), TypeProbe("Sql/Probe/DateTime2", "datetime2_col")); var snapshots = await driver.ReadAsync( [ "Sql/Probe/Bit", "Sql/Probe/Int", "Sql/Probe/BigInt", "Sql/Probe/Real", "Sql/Probe/Float", "Sql/Probe/Decimal", "Sql/Probe/NVarChar", "Sql/Probe/DateTime2", ], TestContext.Current.CancellationToken); foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); // bit → Boolean, not "1"; the CLR type is what the address space declared for the node. snapshots[0].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBit); snapshots[1].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeInt); // bigint stays Int64: the seeded value is past double's exact-integer range, so a slip to // Float64 would come back as a different number rather than as a type-only difference. snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBigInt); snapshots[3].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); snapshots[4].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeFloat); // decimal collapses to Float64 — the documented v1 precision caveat, pinned here so a future // change to that policy cannot land silently. snapshots[5].Value.ShouldBeOfType() .ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9); snapshots[6].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeNVarChar); snapshots[7].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc); } /// /// An authored type wins over the inferred column type, and the coercion runs over the /// provider's real CLR cell rather than over a test double. /// [Fact] public async Task ReadAsync_realSqlServer_declaredTypeOverridesTheInferredColumnType() { SkipUnlessLive(); await using var driver = await StartAsync( TypeProbe("Sql/Probe/IntAsString", "int_col", DriverDataType.String), TypeProbe("Sql/Probe/BitAsInt32", "bit_col", DriverDataType.Int32), TypeProbe("Sql/Probe/RealAsFloat64", "real_col", DriverDataType.Float64)); var snapshots = await driver.ReadAsync( ["Sql/Probe/IntAsString", "Sql/Probe/BitAsInt32", "Sql/Probe/RealAsFloat64"], TestContext.Current.CancellationToken); foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); snapshots[0].Value.ShouldBeOfType() .ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture)); snapshots[1].Value.ShouldBeOfType().ShouldBe(1); snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); } /// /// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone, /// never a thrown read. int.MaxValue into an Int16 is a genuine provider-level /// OverflowException, not a synthesised one. /// [Fact] public async Task ReadAsync_realSqlServer_uncoercibleCellIsBadTypeMismatchAndSpares_itsSiblings() { SkipUnlessLive(); await using var driver = await StartAsync( TypeProbe("Sql/Probe/IntAsInt16", "int_col", DriverDataType.Int16), TypeProbe("Sql/Probe/Float", "float_col")); var snapshots = await driver.ReadAsync( ["Sql/Probe/IntAsInt16", "Sql/Probe/Float"], TestContext.Current.CancellationToken); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch); snapshots[0].Value.ShouldBeNull(); snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); } // ---- cancellation + connection lifecycle ---- /// /// A cancelled poll propagates rather than publishing /// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate. /// What this proves, precisely. The token is already cancelled before /// ReadAsync is called, so the cancellation is observed at the reader's first checkpoint — /// SemaphoreSlim.WaitAsync(_, token)before any SqlConnection opens. It /// therefore does not exercise Microsoft.Data.SqlClient's in-flight cancellation of a /// running command; that is left to the blackhole gate's deadline path /// (SqlBlackholeTimeoutTests). What it pins is the driver-shell contract that a cancelled /// token surfaces as an and is never swallowed into /// a Bad snapshot or misread as an unreachable-database verdict — an offline-equivalent guarantee, /// run here over the real provider only to keep it beside its siblings. /// [Fact] public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding() { SkipUnlessLive(); await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); // ThrowsAnyAsync, not ThrowsAsync: the provider may surface the derived TaskCanceledException, and // which of the two arrives is not a contract this driver makes. await Assert.ThrowsAnyAsync( async () => await driver.ReadAsync(["Sql/Line1/Speed"], cts.Token)); // Cancellation is teardown, not a database verdict: health must be untouched. driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// The reader opens and disposes a connection per poll, which is only sustainable because ADO.NET /// pools them. After many polls the server should still hold a small, non-zero number of /// sessions for this driver's Application Name: non-zero proves the disposed connections /// went back to a pool instead of being torn down, and small proves the poll loop is not leaking /// one per pass. Counting per application name is what makes this safe on a server shared with the /// whole dev rig. /// [Fact] public async Task ReadAsync_realSqlServer_repeatedPollsReusePooledConnections() { SkipUnlessLive(); const int polls = 25; await using var driver = await StartAsync( KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); for (var i = 0; i < polls; i++) { var snapshots = await driver.ReadAsync( ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); } var sessions = await CountDriverSessionsAsync(); sessions.ShouldBeGreaterThan(0, "the disposed connections should still be pooled open server-side"); sessions.ShouldBeLessThan(polls, "a poll loop that leaked a connection per pass would show ~25"); } // ---- INFORMATION_SCHEMA catalog (the browse path's SQL) ---- /// The dialect's schema list — real INFORMATION_SCHEMA.TABLES — sees the seeded schema. [Fact] public async Task Catalog_listSchemasSql_returnsTheSeededSchema() { SkipUnlessLive(); var schemas = await QueryCatalogAsync( new SqlServerDialect().ListSchemasSql, _ => { }, reader => reader.GetString(reader.GetOrdinal("TABLE_SCHEMA"))); schemas.ShouldContain(SqlPollServerFixture.Schema); } /// /// The dialect's table list returns the seeded relations for the bound schema, and flags the view /// as VIEW — the TABLE_TYPE the browse session decorates its label from. /// [Fact] public async Task Catalog_listTablesSql_returnsSeededTablesAndMarksTheView() { SkipUnlessLive(); var rows = await QueryCatalogAsync( new SqlServerDialect().ListTablesSql, command => Bind(command, "@schema", SqlPollServerFixture.Schema), reader => ( Name: reader.GetString(reader.GetOrdinal("TABLE_NAME")), Type: reader.GetString(reader.GetOrdinal("TABLE_TYPE")))); var byName = rows.ToDictionary(r => r.Name, r => r.Type, StringComparer.Ordinal); byName.ShouldContainKeyAndValue(SqlPollServerFixture.KeyValueTable, "BASE TABLE"); byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowTable, "BASE TABLE"); byName.ShouldContainKeyAndValue(SqlPollServerFixture.TypeProbeTable, "BASE TABLE"); byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowView, "VIEW"); } /// /// The dialect's column list returns the key-value table's columns in ordinal order (the /// order the browse tree renders), with DATA_TYPE values the dialect's map recognises. /// [Fact] public async Task Catalog_listColumnsSql_returnsSeededColumnsInOrdinalOrder() { SkipUnlessLive(); var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.KeyValueTable); columns.Select(c => c.Name).ToArray().ShouldBe(new[] { SqlPollServerFixture.KeyColumn, SqlPollServerFixture.ValueColumn, SqlPollServerFixture.TimestampColumn, }); var dialect = new SqlServerDialect(); dialect.MapColumnType(columns[0].DataType).ShouldBe(DriverDataType.String); // nvarchar dialect.MapColumnType(columns[1].DataType).ShouldBe(DriverDataType.Float64); // float dialect.MapColumnType(columns[2].DataType).ShouldBe(DriverDataType.DateTime); // datetime2 // IS_NULLABLE is the third projected column and the browse reads it; prove it is really there. columns[0].IsNullable.ShouldBe("NO"); columns[1].IsNullable.ShouldBe("YES"); } /// /// Every T-SQL family the type-probe table declares maps to the driver type /// SqlServerDialect.MapColumnType promises — read from the catalog's own /// DATA_TYPE strings, which is the exact input the browse side-panel feeds it. /// [Fact] public async Task Catalog_listColumnsSql_typeProbeDataTypesMapAcrossTheTsqlFamilies() { SkipUnlessLive(); var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.TypeProbeTable); var byName = columns.ToDictionary(c => c.Name, c => c.DataType, StringComparer.Ordinal); var dialect = new SqlServerDialect(); dialect.MapColumnType(byName["bit_col"]).ShouldBe(DriverDataType.Boolean); dialect.MapColumnType(byName["int_col"]).ShouldBe(DriverDataType.Int32); dialect.MapColumnType(byName["bigint_col"]).ShouldBe(DriverDataType.Int64); dialect.MapColumnType(byName["real_col"]).ShouldBe(DriverDataType.Float32); dialect.MapColumnType(byName["float_col"]).ShouldBe(DriverDataType.Float64); dialect.MapColumnType(byName["decimal_col"]).ShouldBe(DriverDataType.Float64); dialect.MapColumnType(byName["nvarchar_col"]).ShouldBe(DriverDataType.String); dialect.MapColumnType(byName["datetime2_col"]).ShouldBe(DriverDataType.DateTime); } // ---- helpers ---- /// Skips the calling test when the live-server gate is not configured or not reachable. private void SkipUnlessLive() { if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason); } /// /// Builds and initializes a driver over the seeded database. /// There is no ForProduction / ForTest hatch on /// — its single public constructor takes the resolved connection string, the dialect and an /// optional factory, and leaving the factory unset is precisely what makes this suite exercise /// SqlClientFactory.Instance, the provider the driver ships. /// private Task StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags); /// private async Task StartAsync(bool nullIsBad, params RawTagEntry[] tags) { var options = new SqlDriverOptions { RawTags = tags, NullIsBad = nullIsBad, CommandTimeout = TimeSpan.FromSeconds(10), OperationTimeout = TimeSpan.FromSeconds(15), }; var driver = new SqlDriver( options, driverInstanceId: "sql-integration", dialect: new SqlServerDialect(), connectionString: _sql.ConnectionString); try { await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); } catch { await driver.DisposeAsync(); throw; } return driver; } /// A key-value tag over the seeded EAV table. private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject { ["driver"] = "Sql", ["model"] = "KeyValue", ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable), ["keyColumn"] = SqlPollServerFixture.KeyColumn, ["keyValue"] = key, ["valueColumn"] = SqlPollServerFixture.ValueColumn, ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, }); /// A wide-row tag selected by a whereColumn/whereValue pair. private static RawTagEntry WideRowWhere( string rawPath, string column, string station, string? table = null) => Tag(rawPath, new JsonObject { ["driver"] = "Sql", ["model"] = "WideRow", ["table"] = SqlPollServerFixture.Qualified(table ?? SqlPollServerFixture.WideRowTable), ["columnName"] = column, ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, ["rowSelector"] = new JsonObject { ["whereColumn"] = SqlPollServerFixture.WideRowSelectorColumn, ["whereValue"] = station, }, }); /// A wide-row tag selected by newest timestamp. private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject { ["driver"] = "Sql", ["model"] = "WideRow", ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.WideRowTable), ["columnName"] = column, ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, ["rowSelector"] = new JsonObject { ["topByTimestamp"] = SqlPollServerFixture.TimestampColumn, }, }); /// A wide-row tag over the single-row type-probe table, optionally with a declared type. private static RawTagEntry TypeProbe(string rawPath, string column, DriverDataType? declared = null) { var config = new JsonObject { ["driver"] = "Sql", ["model"] = "WideRow", ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.TypeProbeTable), ["columnName"] = column, ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, ["rowSelector"] = new JsonObject { ["whereColumn"] = SqlPollServerFixture.TypeProbeSelectorColumn, ["whereValue"] = SqlPollServerFixture.TypeProbeRow, }, }; // Absent "type" ⇒ the driver infers from the result set's column metadata, which is the whole // point of the inference test; present ⇒ it overrides. if (declared is not null) config["type"] = declared.Value.ToString(); return Tag(rawPath, config); } /// /// Wraps an authored TagConfig object as a raw tag entry. /// Composed as a rather than as an interpolated string literal on /// purpose: these blobs are brace-dense and nested, and a hand-written literal that loses a brace /// produces a config the parser silently rejects — which surfaces as BadNodeIdUnknown, i.e. /// as a plausible-looking test failure about the driver rather than about the test. /// private static RawTagEntry Tag(string rawPath, JsonObject config) => new(rawPath, config.ToJsonString(), WriteIdempotent: false); /// Runs one of the dialect's catalog statements against the seeded database. private async Task> QueryCatalogAsync( string sql, Action bind, Func project) { var connection = await _sql.OpenInspectionConnectionAsync(); await using (connection.ConfigureAwait(false)) { var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { command.CommandText = sql; bind(command); var results = new List(); var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken); await using (reader.ConfigureAwait(false)) { while (await reader.ReadAsync(TestContext.Current.CancellationToken)) results.Add(project(reader)); } return results; } } } /// Reads one seeded table's catalog columns through the dialect's ListColumnsSql. private Task> ReadCatalogColumnsAsync(string table) => QueryCatalogAsync( new SqlServerDialect().ListColumnsSql, command => { Bind(command, "@schema", SqlPollServerFixture.Schema); Bind(command, "@table", table); }, reader => ( Name: reader.GetString(reader.GetOrdinal("COLUMN_NAME")), DataType: reader.GetString(reader.GetOrdinal("DATA_TYPE")), IsNullable: reader.GetString(reader.GetOrdinal("IS_NULLABLE")))); /// /// Counts the server-side sessions carrying the driver's application name. Skips — rather than /// failing — when the configured login cannot read the DMV, since VIEW SERVER STATE is a /// property of the credentials the operator supplied and not of the code under test. /// private async Task CountDriverSessionsAsync() { try { var counts = await QueryCatalogAsync( "SELECT COUNT(*) AS n FROM sys.dm_exec_sessions WHERE program_name = @app", command => Bind(command, "@app", SqlPollServerFixture.DriverApplicationName), reader => reader.GetInt32(reader.GetOrdinal("n"))); return counts[0]; } catch (SqlException ex) { Assert.Skip( "The configured login cannot read sys.dm_exec_sessions (VIEW SERVER STATE), so connection " + $"pooling cannot be observed from here: {ex.Message}"); throw; // unreachable; Assert.Skip throws. } } /// Binds one string catalog parameter, exactly as the browse session does. private static void Bind(DbCommand command, string name, string value) { var parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.DbType = DbType.String; parameter.Value = value; command.Parameters.Add(parameter); } }