using System.Globalization;
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
///
/// A real, seeded SQL Server database standing in for the source the Sql driver polls —
/// the live counterpart of the offline SqlitePollFixture, and the only place the shipped
/// Microsoft.Data.SqlClient path, the real INFORMATION_SCHEMA catalog SQL, and genuine
/// T-SQL column types are exercised at all.
/// Env-gated, and the gate is checked before any socket is touched. This fixture opens
/// nothing unless — or plus a
/// password — is set; absent, it records and every test calls
/// Assert.Skip. That is what keeps dotnet test genuinely green (and instant) on the
/// macOS dev box this is usually run from, which is the same contract every other
/// *.IntegrationTests fixture in this tree honours.
/// Safety against the shared central server (design §9). The always-on SQL Server on the
/// docker host hosts ConfigDb for the whole dev rig, so this fixture is deliberately
/// conservative:
///
/// - It uses its own database, — the supplied connection
/// string's catalog is used only to reach master and is otherwise ignored, so a
/// mis-set env var cannot aim the seed at somebody else's schema.
/// - A supplied catalog literally named ConfigDb is rejected loudly, at
/// construction, 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.
/// - It creates the database only if missing and never drops a database, so
/// re-running the suite is safe and an operator-created SqlPollFixture is never destroyed.
/// Only the objects inside its own schema are dropped and recreated, which
/// is what makes the seed deterministic across runs.
///
/// The seed is a contract, not scaffolding. The constants below mirror
/// SqlitePollFixture's shape — a present value, a present row with a NULL cell, an
/// absent 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. has no offline counterpart: it exists to pin
/// SqlServerDialect.MapColumnType against the type names SQL Server actually reports,
/// which a hand-written mapping table cannot do.
///
public sealed class SqlPollServerFixture : IAsyncLifetime
{
/// A full ADO.NET connection string for the fixture server. Takes precedence when set.
public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture";
///
/// Server endpoint in host,port form (e.g. 10.100.0.35,14330) — the lighter-weight
/// gate. Requires ; defaults to sa.
///
public const string EndpointEnvVar = "SQL_TEST_ENDPOINT";
/// SQL login for the form. Defaults to sa.
public const string UserEnvVar = "SQL_TEST_USER";
/// SQL password for the form. Required by that form.
public const string PasswordEnvVar = "SQL_TEST_PASSWORD";
/// The database this fixture creates and seeds. Never ConfigDb, never dropped.
public const string FixtureDatabase = "SqlPollFixture";
///
/// The schema every seeded object lives in. Deliberately not dbo: it scopes the
/// drop-and-recreate to objects this fixture owns, and it makes every authored table a
/// two-part name, so the planner's QuoteQualifiedName ([sqlpoll].[TagValues]) is
/// exercised against a real server rather than only in a unit test.
///
public const string Schema = "sqlpoll";
///
/// The Application Name 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.
///
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver";
/// The Application Name the fixture's own inspection connections carry.
private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture";
/// Seconds a connect attempt may take before the fixture calls the server unreachable.
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// The key-value table: tag_name nvarchar(128), num_value float NULL, sample_ts datetime2.
public const string KeyValueTable = "TagValues";
/// The key-value table's key column.
public const string KeyColumn = "tag_name";
/// The key-value table's value column.
public const string ValueColumn = "num_value";
/// The source-timestamp column carried by every seeded table.
public const string TimestampColumn = "sample_ts";
/// A key whose row exists and whose value cell holds .
public const string PresentKey = "Line1.Speed";
/// The value seeded for .
public const double PresentValue = 42.0;
/// A second present key, so a group can legitimately fold more than one member.
public const string SecondPresentKey = "Line1.Pressure";
/// The value seeded for .
public const double SecondPresentValue = 3.5;
/// A key whose row exists but whose value cell is NULL.
public const string NullValueKey = "Line1.Temp";
/// A key with no row at all — a different outcome from .
public const string AbsentKey = "Line1.Missing";
/// The UTC source timestamp seeded for .
public static readonly DateTime PresentKeyTimestampUtc =
new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// The wide-row table: station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2.
public const string WideRowTable = "LatestStatus";
/// The wide-row table's row-selector column.
public const string WideRowSelectorColumn = "station_id";
/// A station whose row exists, with both value columns populated.
public const string PresentStation = "7";
/// 's oven_temp.
public const double PresentStationOvenTemp = 180.5;
/// 's pressure.
public const double PresentStationPressure = 1.2;
///
/// The station holding the newest sample_ts — what a topByTimestamp selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that loses its
/// ORDER BY … DESC or its SELECT TOP 1 picks the wrong one and the test says so.
///
public const string NewestStation = "8";
/// 's oven_temp — what a topByTimestamp plan yields.
public const double NewestStationOvenTemp = 210.25;
/// A station whose row exists but whose oven_temp cell is NULL.
public const string NullOvenTempStation = "9";
/// A station with no row at all.
public const string AbsentStation = "404";
/// A view over — the TABLE_TYPE = 'VIEW' catalog row.
public const string WideRowView = "LatestStatusView";
// ---- T-SQL type-coverage source ----
///
/// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no
/// authored type, each column's driver type is inferred from what
/// SqlDataReader.GetDataTypeName reports — which is the only way to find out whether
/// SqlServerDialect.MapColumnType's table matches reality rather than matching itself.
///
public const string TypeProbeTable = "TypeProbe";
/// The single-row selector column of .
public const string TypeProbeSelectorColumn = "probe_id";
/// The seeded row's value.
public const string TypeProbeRow = "1";
/// Seeded bit value.
public const bool ProbeBit = true;
/// Seeded int value — int.MaxValue, so a narrowing coercion overflows loudly.
public const int ProbeInt = int.MaxValue;
/// Seeded bigint value — beyond exact double range, so an Int64→Float64 slip shows.
public const long ProbeBigInt = 9007199254740993L;
/// Seeded real value; exactly representable, so equality is a fair assertion.
public const float ProbeReal = 1.5f;
/// Seeded float value; exactly representable.
public const double ProbeFloat = 3.25d;
/// Seeded decimal(18,4) value — the documented Float64 precision collapse.
public const decimal ProbeDecimal = 123.4567m;
/// Seeded nvarchar(64) value.
public const string ProbeNVarChar = "hello";
/// Seeded datetime2(3) value, read back as UTC.
public static readonly DateTime ProbeDateTimeUtc =
new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc);
///
/// Why the suite is skipping, or when the fixture is seeded and usable.
/// Tests read this and call Assert.Skip — the house idiom
/// (Snap7ServerFixture / ModbusSimulatorFixture).
///
public string? SkipReason { get; private set; }
///
/// The connection string to hand the code under test: the seeded ,
/// tagged with . Empty while is set.
///
public string ConnectionString { get; private set; } = string.Empty;
/// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.
public string Server { get; private set; } = string.Empty;
/// Connection string the fixture's own inspection connections use. Empty while skipping.
private string _inspectionConnectionString = string.Empty;
///
/// Resolves the gate, creates if missing, and (re)seeds this
/// fixture's schema.
/// The two failure modes are handled deliberately differently: failing to connect is
/// the offline case and becomes a , while failing after the connection
/// opened (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.
///
/// A task that represents the asynchronous operation.
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);
}
/// Holds no long-lived connection, so there is nothing to tear down.
/// A completed task.
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
///
/// 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.
///
/// An open connection the caller owns and must dispose.
public async Task OpenInspectionConnectionAsync()
{
var connection = new SqlConnection(_inspectionConnectionString);
await connection.OpenAsync().ConfigureAwait(false);
return connection;
}
/// Two-part name of a seeded object, in the form an authored tag's table carries.
/// The bare table or view name.
/// The schema.table name.
public static string Qualified(string table) => $"{Schema}.{table}";
///
/// Reads the connection-string gate. Returns — touching no socket — when
/// neither form is configured.
///
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;
}
///
/// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared
/// ConfigDb. The catalog is ignored either way (everything lands in
/// ), so this exists purely to tell an operator their env var is
/// wrong rather than to prevent damage that could otherwise occur.
///
/// The supplied catalog is named ConfigDb.
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).");
}
///
/// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows.
/// 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 schema, which nothing else creates.
///
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);
}
}
///
/// The seed, one batch per statement. CREATE SCHEMA and CREATE VIEW must each be the
/// first statement of their batch in T-SQL, which is why this is a list rather than one script.
/// Every numeric literal is composed under : plain
/// interpolation would emit 180,5 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.
///
private static IEnumerable 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');
""");
}
/// Runs one non-query batch.
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);
}
}
}
///
/// Collection definition so the seed runs once per test session rather than once per test class —
/// the same shape as Snap7ServerCollection.
///
[CollectionDefinition(Name)]
public sealed class SqlPollServerCollection : ICollectionFixture
{
/// The collection name test classes reference.
public const string Name = "SqlPollServer";
}