test(sql): env-gated central-SQL integration fixture + read round-trip
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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>
|
||||
Reference in New Issue
Block a user