test(sql): env-gated central-SQL integration fixture + read round-trip

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:06:55 -04:00
parent 9e7fa5d11f
commit 48cbc26c34
4 changed files with 1172 additions and 0 deletions
@@ -0,0 +1,663 @@
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;
/// <summary>
/// The <c>Sql</c> driver against a <b>real SQL Server</b>, over the <c>Microsoft.Data.SqlClient</c>
/// 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, <c>SELECT TOP 1</c>), and the
/// <c>INFORMATION_SCHEMA</c> catalog SQL that <c>SqlServerDialect</c> carries but that no offline test
/// can reach — the SQLite dialect answers those questions with <c>pragma_table_info</c> instead.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="SqlPollServerFixture"/> 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.</para>
/// <para><b>Out of scope, deliberately:</b> the frozen-database / <c>BadTimeout</c> gate. That needs a
/// <c>docker pause</c>-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.</para>
/// </summary>
[Collection(SqlPollServerCollection.Name)]
public sealed class SqlServerReadTests
{
private readonly SqlPollServerFixture _sql;
/// <summary>Initializes a new instance of the <see cref="SqlServerReadTests"/> class.</summary>
/// <param name="sql">The seeded live-server fixture (collection-scoped).</param>
public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql;
// ---- lifecycle ----
/// <summary>
/// Initialize's liveness check (<c>SELECT 1</c> over a real connection) succeeds and the driver
/// reports Healthy with the endpoint described credential-free.
/// </summary>
[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 ----
/// <summary>The plan's headline case: a seeded key-value row round-trips as a Good snapshot.</summary>
[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);
}
/// <summary>
/// Two keys on the same table fold into ONE query (<c>… WHERE [tag_name] IN (@k0, @k1)</c>) 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 <c>nvarchar</c> key.
/// </summary>
[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);
}
/// <summary>
/// A present row whose value cell is a real T-SQL <c>NULL</c> is Uncertain, not Bad and not
/// BadNoData — the row WAS read.
/// </summary>
[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();
}
/// <summary><c>nullIsBad</c> re-codes the same NULL cell as Bad, and only that cell.</summary>
[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);
}
/// <summary>A key the table has no row for is BadNoData with no source timestamp.</summary>
[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 ----
/// <summary>
/// Two columns of one wide row, selected by a <c>whereColumn/whereValue</c> pair, come back from a
/// single query — including the bound selector value coercing from authored text to a real
/// <c>int</c> column server-side.
/// </summary>
[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);
}
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the T-SQL-specific leg.</b> The planner emits the row limit through the
/// dialect's <c>SingleRowLimitPrefix</c> — <c>SELECT TOP 1 …</c> for T-SQL, where every offline
/// test exercises SQLite's trailing <c>LIMIT 1</c> instead. The seed makes the newest row not the
/// first-inserted one, so losing either the <c>TOP 1</c> or the <c>ORDER BY … DESC</c> yields a
/// different, wrong value rather than a coincidentally-right one.</para>
/// </summary>
[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);
}
/// <summary>A view reads exactly like the table it wraps — the shape operators are told to author.</summary>
[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 ----
/// <summary>
/// With no authored <c>type</c>, each tag's driver type is inferred from the provider's own
/// <c>GetDataTypeName</c> through <c>SqlServerDialect.MapColumnType</c>. This is the only test in
/// the estate where that map is checked against the strings SQL Server <em>actually</em> returns —
/// everywhere else it is fed a hand-written list, i.e. checked against itself.
/// </summary>
[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<bool>().ShouldBe(SqlPollServerFixture.ProbeBit);
snapshots[1].Value.ShouldBeOfType<int>().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<long>().ShouldBe(SqlPollServerFixture.ProbeBigInt);
snapshots[3].Value.ShouldBeOfType<float>().ShouldBe(SqlPollServerFixture.ProbeReal);
snapshots[4].Value.ShouldBeOfType<double>().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<double>()
.ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9);
snapshots[6].Value.ShouldBeOfType<string>().ShouldBe(SqlPollServerFixture.ProbeNVarChar);
snapshots[7].Value.ShouldBeOfType<DateTime>().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc);
}
/// <summary>
/// An authored <c>type</c> wins over the inferred column type, and the coercion runs over the
/// provider's real CLR cell rather than over a test double.
/// </summary>
[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<string>()
.ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture));
snapshots[1].Value.ShouldBeOfType<int>().ShouldBe(1);
snapshots[2].Value.ShouldBeOfType<double>().ShouldBe(SqlPollServerFixture.ProbeReal);
}
/// <summary>
/// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone,
/// never a thrown read. <c>int.MaxValue</c> into an <c>Int16</c> is a genuine provider-level
/// <c>OverflowException</c>, not a synthesised one.
/// </summary>
[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 ----
/// <summary>
/// A cancelled poll propagates <see cref="OperationCanceledException"/> 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, so it is pinned
/// here against the real provider's cancellation path.
/// </summary>
[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<OperationCanceledException>(
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);
}
/// <summary>
/// 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 <b>small, non-zero</b> number of
/// sessions for this driver's <c>Application Name</c>: 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.
/// </summary>
[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) ----
/// <summary>The dialect's schema list — real <c>INFORMATION_SCHEMA.TABLES</c> — sees the seeded schema.</summary>
[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);
}
/// <summary>
/// The dialect's table list returns the seeded relations for the bound schema, and flags the view
/// as <c>VIEW</c> — the <c>TABLE_TYPE</c> the browse session decorates its label from.
/// </summary>
[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");
}
/// <summary>
/// The dialect's column list returns the key-value table's columns <b>in ordinal order</b> (the
/// order the browse tree renders), with <c>DATA_TYPE</c> values the dialect's map recognises.
/// </summary>
[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");
}
/// <summary>
/// Every T-SQL family the type-probe table declares maps to the driver type
/// <c>SqlServerDialect.MapColumnType</c> promises — read from the catalog's own
/// <c>DATA_TYPE</c> strings, which is the exact input the browse side-panel feeds it.
/// </summary>
[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 ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason);
}
/// <summary>
/// Builds and initializes a driver over the seeded database.
/// <para><b>There is no <c>ForProduction</c> / <c>ForTest</c> hatch on <see cref="SqlDriver"/></b>
/// — 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
/// <c>SqlClientFactory.Instance</c>, the provider the driver ships.</para>
/// </summary>
private Task<SqlDriver> StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags);
/// <inheritdoc cref="StartAsync(RawTagEntry[])"/>
private async Task<SqlDriver> 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;
}
/// <summary>A key-value tag over the seeded EAV table.</summary>
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,
});
/// <summary>A wide-row tag selected by a <c>whereColumn</c>/<c>whereValue</c> pair.</summary>
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,
},
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
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,
},
});
/// <summary>A wide-row tag over the single-row type-probe table, optionally with a declared type.</summary>
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);
}
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry.
/// <para>Composed as a <see cref="JsonObject"/> 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 <c>BadNodeIdUnknown</c>, i.e.
/// as a plausible-looking test failure about the driver rather than about the test.</para>
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
/// <summary>Runs one of the dialect's catalog statements against the seeded database.</summary>
private async Task<List<T>> QueryCatalogAsync<T>(
string sql, Action<DbCommand> bind, Func<DbDataReader, T> 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<T>();
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;
}
}
}
/// <summary>Reads one seeded table's catalog columns through the dialect's <c>ListColumnsSql</c>.</summary>
private Task<List<(string Name, string DataType, string IsNullable)>> 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"))));
/// <summary>
/// Counts the server-side sessions carrying the driver's application name. Skips — rather than
/// failing — when the configured login cannot read the DMV, since <c>VIEW SERVER STATE</c> is a
/// property of the credentials the operator supplied and not of the code under test.
/// </summary>
private async Task<int> 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.
}
}
/// <summary>Binds one string catalog parameter, exactly as the browse session does.</summary>
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);
}
}