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 SingleRowLimitPrefix — SELECT 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);
}
}