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
+1
View File
@@ -95,6 +95,7 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
@@ -0,0 +1,469 @@
using System.Globalization;
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// A real, seeded <b>SQL Server</b> database standing in for the source the <c>Sql</c> driver polls —
/// the live counterpart of the offline <c>SqlitePollFixture</c>, and the only place the shipped
/// <c>Microsoft.Data.SqlClient</c> path, the real <c>INFORMATION_SCHEMA</c> catalog SQL, and genuine
/// T-SQL column types are exercised at all.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="ConnectionStringEnvVar"/> — or <see cref="EndpointEnvVar"/> plus a
/// password — is set; absent, it records <see cref="SkipReason"/> and every test calls
/// <c>Assert.Skip</c>. That is what keeps <c>dotnet test</c> genuinely green (and instant) on the
/// macOS dev box this is usually run from, which is the same contract every other
/// <c>*.IntegrationTests</c> fixture in this tree honours.</para>
/// <para><b>Safety against the shared central server (design §9).</b> The always-on SQL Server on the
/// docker host hosts <c>ConfigDb</c> for the whole dev rig, so this fixture is deliberately
/// conservative:</para>
/// <list type="bullet">
/// <item>It uses <b>its own database</b>, <see cref="FixtureDatabase"/> — the supplied connection
/// string's catalog is used only to reach <c>master</c> and is otherwise <b>ignored</b>, so a
/// mis-set env var cannot aim the seed at somebody else's schema.</item>
/// <item>A supplied catalog literally named <c>ConfigDb</c> is rejected <b>loudly, at
/// construction</b>, rather than quietly retargeted — it is the signature of a copied-and-pasted
/// connection string, and the operator should know their env var is wrong.</item>
/// <item>It <b>creates the database only if missing</b> and <b>never drops a database</b>, so
/// re-running the suite is safe and an operator-created <c>SqlPollFixture</c> is never destroyed.
/// Only the objects inside its own <see cref="Schema"/> schema are dropped and recreated, which
/// is what makes the seed deterministic across runs.</item>
/// </list>
/// <para><b>The seed is a contract, not scaffolding.</b> The constants below mirror
/// <c>SqlitePollFixture</c>'s shape — a present value, a present row with a <b>NULL</b> cell, an
/// <b>absent</b> key, and a wide-row table whose newest row is deliberately not its first — so the
/// same assertions can be made offline and live, and a divergence between the SQLite and SQL Server
/// paths shows up as a failing assertion rather than as two suites that quietly test different
/// things. <see cref="TypeProbeTable"/> has no offline counterpart: it exists to pin
/// <c>SqlServerDialect.MapColumnType</c> against the type names SQL Server <em>actually</em> reports,
/// which a hand-written mapping table cannot do.</para>
/// </summary>
public sealed class SqlPollServerFixture : IAsyncLifetime
{
/// <summary>A full ADO.NET connection string for the fixture server. Takes precedence when set.</summary>
public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture";
/// <summary>
/// Server endpoint in <c>host,port</c> form (e.g. <c>10.100.0.35,14330</c>) — the lighter-weight
/// gate. Requires <see cref="PasswordEnvVar"/>; <see cref="UserEnvVar"/> defaults to <c>sa</c>.
/// </summary>
public const string EndpointEnvVar = "SQL_TEST_ENDPOINT";
/// <summary>SQL login for the <see cref="EndpointEnvVar"/> form. Defaults to <c>sa</c>.</summary>
public const string UserEnvVar = "SQL_TEST_USER";
/// <summary>SQL password for the <see cref="EndpointEnvVar"/> form. Required by that form.</summary>
public const string PasswordEnvVar = "SQL_TEST_PASSWORD";
/// <summary>The database this fixture creates and seeds. Never <c>ConfigDb</c>, never dropped.</summary>
public const string FixtureDatabase = "SqlPollFixture";
/// <summary>
/// The schema every seeded object lives in. Deliberately <b>not</b> <c>dbo</c>: it scopes the
/// drop-and-recreate to objects this fixture owns, and it makes every authored <c>table</c> a
/// two-part name, so the planner's <c>QuoteQualifiedName</c> (<c>[sqlpoll].[TagValues]</c>) is
/// exercised against a real server rather than only in a unit test.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>
/// The <c>Application Name</c> the driver's connections carry, so the pooling assertion can count
/// exactly this suite's sessions on a server shared with the whole dev rig.
/// </summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver";
/// <summary>The <c>Application Name</c> the fixture's own inspection connections carry.</summary>
private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name nvarchar(128), num_value float NULL, sample_ts datetime2</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>A key whose row <b>exists</b> but whose value cell is <c>NULL</c>.</summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>A key with <b>no row at all</b> — a different outcome from <see cref="NullValueKey"/>.</summary>
public const string AbsentKey = "Line1.Missing";
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc =
new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2</c>.</summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that loses its
/// <c>ORDER BY … DESC</c> or its <c>SELECT TOP 1</c> picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — what a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
/// <summary>A view over <see cref="WideRowTable"/> — the <c>TABLE_TYPE = 'VIEW'</c> catalog row.</summary>
public const string WideRowView = "LatestStatusView";
// ---- T-SQL type-coverage source ----
/// <summary>
/// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no
/// authored <c>type</c>, each column's driver type is inferred from what
/// <c>SqlDataReader.GetDataTypeName</c> reports — which is the only way to find out whether
/// <c>SqlServerDialect.MapColumnType</c>'s table matches reality rather than matching itself.
/// </summary>
public const string TypeProbeTable = "TypeProbe";
/// <summary>The single-row selector column of <see cref="TypeProbeTable"/>.</summary>
public const string TypeProbeSelectorColumn = "probe_id";
/// <summary>The seeded row's <see cref="TypeProbeSelectorColumn"/> value.</summary>
public const string TypeProbeRow = "1";
/// <summary>Seeded <c>bit</c> value.</summary>
public const bool ProbeBit = true;
/// <summary>Seeded <c>int</c> value — <c>int.MaxValue</c>, so a narrowing coercion overflows loudly.</summary>
public const int ProbeInt = int.MaxValue;
/// <summary>Seeded <c>bigint</c> value — beyond exact <c>double</c> range, so an Int64→Float64 slip shows.</summary>
public const long ProbeBigInt = 9007199254740993L;
/// <summary>Seeded <c>real</c> value; exactly representable, so equality is a fair assertion.</summary>
public const float ProbeReal = 1.5f;
/// <summary>Seeded <c>float</c> value; exactly representable.</summary>
public const double ProbeFloat = 3.25d;
/// <summary>Seeded <c>decimal(18,4)</c> value — the documented Float64 precision collapse.</summary>
public const decimal ProbeDecimal = 123.4567m;
/// <summary>Seeded <c>nvarchar(64)</c> value.</summary>
public const string ProbeNVarChar = "hello";
/// <summary>Seeded <c>datetime2(3)</c> value, read back as UTC.</summary>
public static readonly DateTime ProbeDateTimeUtc =
new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc);
/// <summary>
/// Why the suite is skipping, or <see langword="null"/> when the fixture is seeded and usable.
/// Tests read this and call <c>Assert.Skip</c> — the house idiom
/// (<c>Snap7ServerFixture</c> / <c>ModbusSimulatorFixture</c>).
/// </summary>
public string? SkipReason { get; private set; }
/// <summary>
/// The connection string to hand the code under test: the seeded <see cref="FixtureDatabase"/>,
/// tagged with <see cref="DriverApplicationName"/>. Empty while <see cref="SkipReason"/> is set.
/// </summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Connection string the fixture's own inspection connections use. Empty while skipping.</summary>
private string _inspectionConnectionString = string.Empty;
/// <summary>
/// Resolves the gate, creates <see cref="FixtureDatabase"/> if missing, and (re)seeds this
/// fixture's schema.
/// <para>The two failure modes are handled deliberately differently: <b>failing to connect</b> is
/// the offline case and becomes a <see cref="SkipReason"/>, while <b>failing after the connection
/// opened</b> (DDL, seeding) is a real fault against a reachable server and is allowed to throw —
/// swallowing that would leave the suite silently testing an unseeded database.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } supplied)
{
SkipReason =
$"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " +
$"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " +
"the Sql driver's live SQL Server suite. No connection is attempted without it.";
return;
}
var builder = new SqlConnectionStringBuilder(supplied);
GuardAgainstConfigDb(builder);
if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds;
Server = builder.DataSource;
// The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase.
var master = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = "master",
ApplicationName = FixtureApplicationName,
}.ConnectionString;
SqlConnection connection;
try
{
connection = new SqlConnection(master);
await connection.OpenAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
SkipReason =
$"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
$"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " +
$"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite.";
return;
}
await using (connection.ConfigureAwait(false))
{
// Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever
// made it, keeps everything outside this fixture's own schema.
await ExecuteAsync(
connection,
$"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];")
.ConfigureAwait(false);
}
ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = FixtureDatabase,
ApplicationName = DriverApplicationName,
}.ConnectionString;
_inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = FixtureDatabase,
ApplicationName = FixtureApplicationName,
}.ConnectionString;
await SeedAsync().ConfigureAwait(false);
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged
/// with the fixture's own application name so it is never counted by the pooling assertion.
/// </summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public async Task<SqlConnection> OpenInspectionConnectionAsync()
{
var connection = new SqlConnection(_inspectionConnectionString);
await connection.OpenAsync().ConfigureAwait(false);
return connection;
}
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Reads the connection-string gate. Returns <see langword="null"/> — touching no socket — when
/// neither form is configured.
/// </summary>
private static string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full)
return full;
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint)
return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login against a shared server, which is how accounts get locked out.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password)
return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa";
return new SqlConnectionStringBuilder
{
DataSource = endpoint,
InitialCatalog = FixtureDatabase,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
}.ConnectionString;
}
/// <summary>
/// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared
/// <c>ConfigDb</c>. The catalog is ignored either way (everything lands in
/// <see cref="FixtureDatabase"/>), so this exists purely to tell an operator their env var is
/// wrong rather than to prevent damage that could otherwise occur.
/// </summary>
/// <exception cref="InvalidOperationException">The supplied catalog is named <c>ConfigDb</c>.</exception>
private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder)
{
if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return;
throw new InvalidOperationException(
$"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " +
$"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " +
"never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog).");
}
/// <summary>
/// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows.
/// <para>Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract:
/// a leftover row from an older revision of this file would make a passing test meaningless. The
/// blast radius is bounded to the <see cref="Schema"/> schema, which nothing else creates.</para>
/// </summary>
private async Task SeedAsync()
{
var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
// Views first: a view over a dropped table blocks nothing, but dropping the table under a live
// view leaves the catalog reporting a column set that no longer resolves.
foreach (var statement in SeedStatements())
await ExecuteAsync(connection, statement).ConfigureAwait(false);
}
}
/// <summary>
/// The seed, one batch per statement. <c>CREATE SCHEMA</c> and <c>CREATE VIEW</c> must each be the
/// first statement of their batch in T-SQL, which is why this is a list rather than one script.
/// <para>Every numeric literal is composed under <see cref="CultureInfo.InvariantCulture"/>: plain
/// interpolation would emit <c>180,5</c> on a comma-decimal machine, and T-SQL would read that as
/// two column values rather than as one wrong number — a seed failure with a baffling message.</para>
/// </summary>
private static IEnumerable<string> SeedStatements()
{
yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];";
yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');";
yield return $"""
CREATE TABLE [{Schema}].[{KeyValueTable}] (
[{KeyColumn}] nvarchar(128) NOT NULL,
[{ValueColumn}] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}])
VALUES (N'{PresentKey}', {PresentValue}, '2026-07-24T10:00:00'),
(N'{NullValueKey}', NULL, '2026-07-24T10:00:01'),
(N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02');
""");
yield return $"""
CREATE TABLE [{Schema}].[{WideRowTable}] (
[{WideRowSelectorColumn}] int NOT NULL,
[oven_temp] float NULL,
[pressure] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
// NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A
// topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two
// orderings are kept deliberately different.
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{WideRowTable}]
([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}])
VALUES ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'),
({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01'),
({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02');
""");
yield return $"""
CREATE VIEW [{Schema}].[{WideRowView}] AS
SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]
FROM [{Schema}].[{WideRowTable}];
""";
yield return $"""
CREATE TABLE [{Schema}].[{TypeProbeTable}] (
[{TypeProbeSelectorColumn}] int NOT NULL,
[bit_col] bit NULL,
[int_col] int NULL,
[bigint_col] bigint NULL,
[real_col] real NULL,
[float_col] float NULL,
[decimal_col] decimal(18,4) NULL,
[nvarchar_col] nvarchar(64) NULL,
[datetime2_col] datetime2(3) NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{TypeProbeTable}]
([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col],
[decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}])
VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat},
{ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03');
""");
}
/// <summary>Runs one non-query batch.</summary>
private static async Task ExecuteAsync(SqlConnection connection, string sql)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Collection definition so the seed runs once per test session rather than once per test class —
/// the same shape as <c>Snap7ServerCollection</c>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class SqlPollServerCollection : ICollectionFixture<SqlPollServerFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPollServer";
}
@@ -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);
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise the real Microsoft.Data.SqlClient path — the analyzer's documented intentional case
("move the suppression into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!--
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
the two versions drift and the suite would then prove something about a provider nobody deploys.
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
</Project>