diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 033ff3d3..8b786bac 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -95,6 +95,7 @@
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs
new file mode 100644
index 00000000..16c0ed61
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs
@@ -0,0 +1,469 @@
+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";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs
new file mode 100644
index 00000000..c258ff6b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs
@@ -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;
+
+///
+/// The Sql driver against a real SQL Server, over the Microsoft.Data.SqlClient
+/// provider it actually ships. Everything else in this driver's test estate runs on SQLite, which
+/// means three things have never been exercised anywhere until this suite: the shipped ADO.NET
+/// provider, T-SQL's own syntax (bracket-quoted two-part names, SELECT TOP 1), and the
+/// INFORMATION_SCHEMA catalog SQL that SqlServerDialect carries but that no offline test
+/// can reach — the SQLite dialect answers those questions with pragma_table_info instead.
+/// Env-gated; skipping is the normal outcome. See for
+/// the gate. With it unset nothing here opens a socket, so the suite is instant and green offline —
+/// which is the point: a live test that goes red on a laptop teaches people to ignore red.
+/// Out of scope, deliberately: the frozen-database / BadTimeout gate. That needs a
+/// docker pause-able container this suite must never own, because the server it points at is
+/// the rig's shared central SQL Server. It is a separate task with its own dedicated mssql
+/// container.
+///
+[Collection(SqlPollServerCollection.Name)]
+public sealed class SqlServerReadTests
+{
+ private readonly SqlPollServerFixture _sql;
+
+ /// Initializes a new instance of the class.
+ /// The seeded live-server fixture (collection-scoped).
+ public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql;
+
+ // ---- lifecycle ----
+
+ ///
+ /// Initialize's liveness check (SELECT 1 over a real connection) succeeds and the driver
+ /// reports Healthy with the endpoint described credential-free.
+ ///
+ [Fact]
+ public async Task InitializeAsync_realSqlServer_reportsHealthyAndDescribesTheEndpoint()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey));
+
+ var health = driver.GetHealth();
+ health.State.ShouldBe(DriverState.Healthy);
+ health.LastSuccessfulRead.ShouldNotBeNull();
+
+ // The endpoint description is what every log line and the Admin UI show. It must name the
+ // database and must not carry the password that reached the provider.
+ driver.Endpoint.ShouldContain(SqlPollServerFixture.FixtureDatabase);
+ driver.Endpoint.ShouldNotContain("Password");
+
+ driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
+ }
+
+ // ---- key-value model ----
+
+ /// The plan's headline case: a seeded key-value row round-trips as a Good snapshot.
+ [Fact]
+ public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey));
+
+ var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
+
+ var snapshot = snapshots.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentValue);
+ // datetime2 comes back Unspecified; the reader stamps it UTC rather than reinterpreting it
+ // through the host's zone, which is the behaviour this asserts against a real column type.
+ snapshot.SourceTimestampUtc.ShouldBe(SqlPollServerFixture.PresentKeyTimestampUtc);
+ }
+
+ ///
+ /// Two keys on the same table fold into ONE query (… WHERE [tag_name] IN (@k0, @k1)) and are
+ /// sliced back to the right tags. Against a real server this also proves the parameter binding and
+ /// the key-column stringification survive a genuine nvarchar key.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey),
+ KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey));
+
+ var snapshots = await driver.ReadAsync(
+ ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
+
+ snapshots.Count.ShouldBe(2);
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentValue);
+ snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.SecondPresentValue);
+ }
+
+ ///
+ /// A present row whose value cell is a real T-SQL NULL is Uncertain, not Bad and not
+ /// BadNoData — the row WAS read.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_nullValueCellIsUncertain()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Uncertain);
+ snapshot.Value.ShouldBeNull();
+ // The row exists, so its timestamp is still readable — that is exactly what distinguishes this
+ // from the absent-key case below.
+ snapshot.SourceTimestampUtc.ShouldNotBeNull();
+ }
+
+ /// nullIsBad re-codes the same NULL cell as Bad, and only that cell.
+ [Fact]
+ public async Task ReadAsync_realSqlServer_nullValueCellIsBadWhenNullIsBadIsSet()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ nullIsBad: true, KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Bad);
+ }
+
+ /// A key the table has no row for is BadNoData with no source timestamp.
+ [Fact]
+ public async Task ReadAsync_realSqlServer_absentKeyIsBadNoData()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Missing", SqlPollServerFixture.AbsentKey));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Line1/Missing"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
+ snapshot.SourceTimestampUtc.ShouldBeNull();
+ }
+
+ // ---- wide-row model ----
+
+ ///
+ /// Two columns of one wide row, selected by a whereColumn/whereValue pair, come back from a
+ /// single query — including the bound selector value coercing from authored text to a real
+ /// int column server-side.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_wideRowSelectorReadsEveryColumnFromOneRow()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ WideRowWhere("Sql/Station7/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation),
+ WideRowWhere("Sql/Station7/Pressure", "pressure", SqlPollServerFixture.PresentStation),
+ WideRowWhere("Sql/Station404/OvenTemp", "oven_temp", SqlPollServerFixture.AbsentStation));
+
+ var snapshots = await driver.ReadAsync(
+ ["Sql/Station7/OvenTemp", "Sql/Station7/Pressure", "Sql/Station404/OvenTemp"],
+ TestContext.Current.CancellationToken);
+
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp);
+ snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.PresentStationPressure);
+ // A selector matching no row is BadNoData, not an error — and it does not disturb its siblings.
+ snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
+ }
+
+ ///
+ /// The topByTimestamp selector resolves to the newest row.
+ /// This is the T-SQL-specific leg. The planner emits the row limit through the
+ /// dialect's SingleRowLimitPrefix — SELECT TOP 1 … for T-SQL, where every offline
+ /// test exercises SQLite's trailing LIMIT 1 instead. The seed makes the newest row not the
+ /// first-inserted one, so losing either the TOP 1 or the ORDER BY … DESC yields a
+ /// different, wrong value rather than a coincidentally-right one.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_topByTimestampSelectsTheNewestRow()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp"));
+
+ var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.NewestStationOvenTemp);
+ }
+
+ /// A view reads exactly like the table it wraps — the shape operators are told to author.
+ [Fact]
+ public async Task ReadAsync_realSqlServer_readsThroughAView()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ WideRowWhere(
+ "Sql/View/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation,
+ table: SqlPollServerFixture.WideRowView));
+
+ var snapshot = (await driver.ReadAsync(["Sql/View/OvenTemp"], TestContext.Current.CancellationToken))
+ .ShouldHaveSingleItem();
+
+ snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp);
+ }
+
+ // ---- type mapping against real T-SQL column types ----
+
+ ///
+ /// With no authored type, each tag's driver type is inferred from the provider's own
+ /// GetDataTypeName through SqlServerDialect.MapColumnType. This is the only test in
+ /// the estate where that map is checked against the strings SQL Server actually returns —
+ /// everywhere else it is fed a hand-written list, i.e. checked against itself.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_infersDriverTypesFromRealTsqlColumnTypes()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ TypeProbe("Sql/Probe/Bit", "bit_col"),
+ TypeProbe("Sql/Probe/Int", "int_col"),
+ TypeProbe("Sql/Probe/BigInt", "bigint_col"),
+ TypeProbe("Sql/Probe/Real", "real_col"),
+ TypeProbe("Sql/Probe/Float", "float_col"),
+ TypeProbe("Sql/Probe/Decimal", "decimal_col"),
+ TypeProbe("Sql/Probe/NVarChar", "nvarchar_col"),
+ TypeProbe("Sql/Probe/DateTime2", "datetime2_col"));
+
+ var snapshots = await driver.ReadAsync(
+ [
+ "Sql/Probe/Bit", "Sql/Probe/Int", "Sql/Probe/BigInt", "Sql/Probe/Real",
+ "Sql/Probe/Float", "Sql/Probe/Decimal", "Sql/Probe/NVarChar", "Sql/Probe/DateTime2",
+ ],
+ TestContext.Current.CancellationToken);
+
+ foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+
+ // bit → Boolean, not "1"; the CLR type is what the address space declared for the node.
+ snapshots[0].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBit);
+ snapshots[1].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeInt);
+ // bigint stays Int64: the seeded value is past double's exact-integer range, so a slip to
+ // Float64 would come back as a different number rather than as a type-only difference.
+ snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBigInt);
+ snapshots[3].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal);
+ snapshots[4].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeFloat);
+ // decimal collapses to Float64 — the documented v1 precision caveat, pinned here so a future
+ // change to that policy cannot land silently.
+ snapshots[5].Value.ShouldBeOfType()
+ .ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9);
+ snapshots[6].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeNVarChar);
+ snapshots[7].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc);
+ }
+
+ ///
+ /// An authored type wins over the inferred column type, and the coercion runs over the
+ /// provider's real CLR cell rather than over a test double.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_declaredTypeOverridesTheInferredColumnType()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ TypeProbe("Sql/Probe/IntAsString", "int_col", DriverDataType.String),
+ TypeProbe("Sql/Probe/BitAsInt32", "bit_col", DriverDataType.Int32),
+ TypeProbe("Sql/Probe/RealAsFloat64", "real_col", DriverDataType.Float64));
+
+ var snapshots = await driver.ReadAsync(
+ ["Sql/Probe/IntAsString", "Sql/Probe/BitAsInt32", "Sql/Probe/RealAsFloat64"],
+ TestContext.Current.CancellationToken);
+
+ foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ snapshots[0].Value.ShouldBeOfType()
+ .ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture));
+ snapshots[1].Value.ShouldBeOfType().ShouldBe(1);
+ snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal);
+ }
+
+ ///
+ /// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone,
+ /// never a thrown read. int.MaxValue into an Int16 is a genuine provider-level
+ /// OverflowException, not a synthesised one.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_uncoercibleCellIsBadTypeMismatchAndSpares_itsSiblings()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(
+ TypeProbe("Sql/Probe/IntAsInt16", "int_col", DriverDataType.Int16),
+ TypeProbe("Sql/Probe/Float", "float_col"));
+
+ var snapshots = await driver.ReadAsync(
+ ["Sql/Probe/IntAsInt16", "Sql/Probe/Float"], TestContext.Current.CancellationToken);
+
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch);
+ snapshots[0].Value.ShouldBeNull();
+ snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
+ }
+
+ // ---- cancellation + connection lifecycle ----
+
+ ///
+ /// A cancelled poll propagates rather than publishing
+ /// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The
+ /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate, so it is pinned
+ /// here against the real provider's cancellation path.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding()
+ {
+ SkipUnlessLive();
+
+ await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey));
+
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ // ThrowsAnyAsync, not ThrowsAsync: the provider may surface the derived TaskCanceledException, and
+ // which of the two arrives is not a contract this driver makes.
+ await Assert.ThrowsAnyAsync(
+ async () => await driver.ReadAsync(["Sql/Line1/Speed"], cts.Token));
+
+ // Cancellation is teardown, not a database verdict: health must be untouched.
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ ///
+ /// The reader opens and disposes a connection per poll, which is only sustainable because ADO.NET
+ /// pools them. After many polls the server should still hold a small, non-zero number of
+ /// sessions for this driver's Application Name: non-zero proves the disposed connections
+ /// went back to a pool instead of being torn down, and small proves the poll loop is not leaking
+ /// one per pass. Counting per application name is what makes this safe on a server shared with the
+ /// whole dev rig.
+ ///
+ [Fact]
+ public async Task ReadAsync_realSqlServer_repeatedPollsReusePooledConnections()
+ {
+ SkipUnlessLive();
+
+ const int polls = 25;
+ await using var driver = await StartAsync(
+ KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey),
+ KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey));
+
+ for (var i = 0; i < polls; i++)
+ {
+ var snapshots = await driver.ReadAsync(
+ ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
+ foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
+ }
+
+ var sessions = await CountDriverSessionsAsync();
+ sessions.ShouldBeGreaterThan(0, "the disposed connections should still be pooled open server-side");
+ sessions.ShouldBeLessThan(polls, "a poll loop that leaked a connection per pass would show ~25");
+ }
+
+ // ---- INFORMATION_SCHEMA catalog (the browse path's SQL) ----
+
+ /// The dialect's schema list — real INFORMATION_SCHEMA.TABLES — sees the seeded schema.
+ [Fact]
+ public async Task Catalog_listSchemasSql_returnsTheSeededSchema()
+ {
+ SkipUnlessLive();
+
+ var schemas = await QueryCatalogAsync(
+ new SqlServerDialect().ListSchemasSql,
+ _ => { },
+ reader => reader.GetString(reader.GetOrdinal("TABLE_SCHEMA")));
+
+ schemas.ShouldContain(SqlPollServerFixture.Schema);
+ }
+
+ ///
+ /// The dialect's table list returns the seeded relations for the bound schema, and flags the view
+ /// as VIEW — the TABLE_TYPE the browse session decorates its label from.
+ ///
+ [Fact]
+ public async Task Catalog_listTablesSql_returnsSeededTablesAndMarksTheView()
+ {
+ SkipUnlessLive();
+
+ var rows = await QueryCatalogAsync(
+ new SqlServerDialect().ListTablesSql,
+ command => Bind(command, "@schema", SqlPollServerFixture.Schema),
+ reader => (
+ Name: reader.GetString(reader.GetOrdinal("TABLE_NAME")),
+ Type: reader.GetString(reader.GetOrdinal("TABLE_TYPE"))));
+
+ var byName = rows.ToDictionary(r => r.Name, r => r.Type, StringComparer.Ordinal);
+ byName.ShouldContainKeyAndValue(SqlPollServerFixture.KeyValueTable, "BASE TABLE");
+ byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowTable, "BASE TABLE");
+ byName.ShouldContainKeyAndValue(SqlPollServerFixture.TypeProbeTable, "BASE TABLE");
+ byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowView, "VIEW");
+ }
+
+ ///
+ /// The dialect's column list returns the key-value table's columns in ordinal order (the
+ /// order the browse tree renders), with DATA_TYPE values the dialect's map recognises.
+ ///
+ [Fact]
+ public async Task Catalog_listColumnsSql_returnsSeededColumnsInOrdinalOrder()
+ {
+ SkipUnlessLive();
+
+ var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.KeyValueTable);
+
+ columns.Select(c => c.Name).ToArray().ShouldBe(new[]
+ {
+ SqlPollServerFixture.KeyColumn,
+ SqlPollServerFixture.ValueColumn,
+ SqlPollServerFixture.TimestampColumn,
+ });
+
+ var dialect = new SqlServerDialect();
+ dialect.MapColumnType(columns[0].DataType).ShouldBe(DriverDataType.String); // nvarchar
+ dialect.MapColumnType(columns[1].DataType).ShouldBe(DriverDataType.Float64); // float
+ dialect.MapColumnType(columns[2].DataType).ShouldBe(DriverDataType.DateTime); // datetime2
+
+ // IS_NULLABLE is the third projected column and the browse reads it; prove it is really there.
+ columns[0].IsNullable.ShouldBe("NO");
+ columns[1].IsNullable.ShouldBe("YES");
+ }
+
+ ///
+ /// Every T-SQL family the type-probe table declares maps to the driver type
+ /// SqlServerDialect.MapColumnType promises — read from the catalog's own
+ /// DATA_TYPE strings, which is the exact input the browse side-panel feeds it.
+ ///
+ [Fact]
+ public async Task Catalog_listColumnsSql_typeProbeDataTypesMapAcrossTheTsqlFamilies()
+ {
+ SkipUnlessLive();
+
+ var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.TypeProbeTable);
+ var byName = columns.ToDictionary(c => c.Name, c => c.DataType, StringComparer.Ordinal);
+ var dialect = new SqlServerDialect();
+
+ dialect.MapColumnType(byName["bit_col"]).ShouldBe(DriverDataType.Boolean);
+ dialect.MapColumnType(byName["int_col"]).ShouldBe(DriverDataType.Int32);
+ dialect.MapColumnType(byName["bigint_col"]).ShouldBe(DriverDataType.Int64);
+ dialect.MapColumnType(byName["real_col"]).ShouldBe(DriverDataType.Float32);
+ dialect.MapColumnType(byName["float_col"]).ShouldBe(DriverDataType.Float64);
+ dialect.MapColumnType(byName["decimal_col"]).ShouldBe(DriverDataType.Float64);
+ dialect.MapColumnType(byName["nvarchar_col"]).ShouldBe(DriverDataType.String);
+ dialect.MapColumnType(byName["datetime2_col"]).ShouldBe(DriverDataType.DateTime);
+ }
+
+ // ---- helpers ----
+
+ /// Skips the calling test when the live-server gate is not configured or not reachable.
+ private void SkipUnlessLive()
+ {
+ if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason);
+ }
+
+ ///
+ /// Builds and initializes a driver over the seeded database.
+ /// There is no ForProduction / ForTest hatch on
+ /// — its single public constructor takes the resolved connection string, the dialect and an
+ /// optional factory, and leaving the factory unset is precisely what makes this suite exercise
+ /// SqlClientFactory.Instance, the provider the driver ships.
+ ///
+ private Task StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags);
+
+ ///
+ private async Task StartAsync(bool nullIsBad, params RawTagEntry[] tags)
+ {
+ var options = new SqlDriverOptions
+ {
+ RawTags = tags,
+ NullIsBad = nullIsBad,
+ CommandTimeout = TimeSpan.FromSeconds(10),
+ OperationTimeout = TimeSpan.FromSeconds(15),
+ };
+
+ var driver = new SqlDriver(
+ options,
+ driverInstanceId: "sql-integration",
+ dialect: new SqlServerDialect(),
+ connectionString: _sql.ConnectionString);
+
+ try
+ {
+ await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ }
+ catch
+ {
+ await driver.DisposeAsync();
+ throw;
+ }
+
+ return driver;
+ }
+
+ /// A key-value tag over the seeded EAV table.
+ private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
+ {
+ ["driver"] = "Sql",
+ ["model"] = "KeyValue",
+ ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable),
+ ["keyColumn"] = SqlPollServerFixture.KeyColumn,
+ ["keyValue"] = key,
+ ["valueColumn"] = SqlPollServerFixture.ValueColumn,
+ ["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
+ });
+
+ /// A wide-row tag selected by a whereColumn/whereValue pair.
+ private static RawTagEntry WideRowWhere(
+ string rawPath, string column, string station, string? table = null) => Tag(rawPath, new JsonObject
+ {
+ ["driver"] = "Sql",
+ ["model"] = "WideRow",
+ ["table"] = SqlPollServerFixture.Qualified(table ?? SqlPollServerFixture.WideRowTable),
+ ["columnName"] = column,
+ ["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
+ ["rowSelector"] = new JsonObject
+ {
+ ["whereColumn"] = SqlPollServerFixture.WideRowSelectorColumn,
+ ["whereValue"] = station,
+ },
+ });
+
+ /// A wide-row tag selected by newest timestamp.
+ private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
+ {
+ ["driver"] = "Sql",
+ ["model"] = "WideRow",
+ ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.WideRowTable),
+ ["columnName"] = column,
+ ["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
+ ["rowSelector"] = new JsonObject
+ {
+ ["topByTimestamp"] = SqlPollServerFixture.TimestampColumn,
+ },
+ });
+
+ /// A wide-row tag over the single-row type-probe table, optionally with a declared type.
+ private static RawTagEntry TypeProbe(string rawPath, string column, DriverDataType? declared = null)
+ {
+ var config = new JsonObject
+ {
+ ["driver"] = "Sql",
+ ["model"] = "WideRow",
+ ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.TypeProbeTable),
+ ["columnName"] = column,
+ ["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
+ ["rowSelector"] = new JsonObject
+ {
+ ["whereColumn"] = SqlPollServerFixture.TypeProbeSelectorColumn,
+ ["whereValue"] = SqlPollServerFixture.TypeProbeRow,
+ },
+ };
+
+ // Absent "type" ⇒ the driver infers from the result set's column metadata, which is the whole
+ // point of the inference test; present ⇒ it overrides.
+ if (declared is not null) config["type"] = declared.Value.ToString();
+ return Tag(rawPath, config);
+ }
+
+ ///
+ /// Wraps an authored TagConfig object as a raw tag entry.
+ /// Composed as a rather than as an interpolated string literal on
+ /// purpose: these blobs are brace-dense and nested, and a hand-written literal that loses a brace
+ /// produces a config the parser silently rejects — which surfaces as BadNodeIdUnknown, i.e.
+ /// as a plausible-looking test failure about the driver rather than about the test.
+ ///
+ private static RawTagEntry Tag(string rawPath, JsonObject config) =>
+ new(rawPath, config.ToJsonString(), WriteIdempotent: false);
+
+ /// Runs one of the dialect's catalog statements against the seeded database.
+ private async Task> QueryCatalogAsync(
+ string sql, Action bind, Func project)
+ {
+ var connection = await _sql.OpenInspectionConnectionAsync();
+ await using (connection.ConfigureAwait(false))
+ {
+ var command = connection.CreateCommand();
+ await using (command.ConfigureAwait(false))
+ {
+ command.CommandText = sql;
+ bind(command);
+
+ var results = new List();
+ var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
+ await using (reader.ConfigureAwait(false))
+ {
+ while (await reader.ReadAsync(TestContext.Current.CancellationToken))
+ results.Add(project(reader));
+ }
+
+ return results;
+ }
+ }
+ }
+
+ /// Reads one seeded table's catalog columns through the dialect's ListColumnsSql.
+ private Task> ReadCatalogColumnsAsync(string table) =>
+ QueryCatalogAsync(
+ new SqlServerDialect().ListColumnsSql,
+ command =>
+ {
+ Bind(command, "@schema", SqlPollServerFixture.Schema);
+ Bind(command, "@table", table);
+ },
+ reader => (
+ Name: reader.GetString(reader.GetOrdinal("COLUMN_NAME")),
+ DataType: reader.GetString(reader.GetOrdinal("DATA_TYPE")),
+ IsNullable: reader.GetString(reader.GetOrdinal("IS_NULLABLE"))));
+
+ ///
+ /// Counts the server-side sessions carrying the driver's application name. Skips — rather than
+ /// failing — when the configured login cannot read the DMV, since VIEW SERVER STATE is a
+ /// property of the credentials the operator supplied and not of the code under test.
+ ///
+ private async Task CountDriverSessionsAsync()
+ {
+ try
+ {
+ var counts = await QueryCatalogAsync(
+ "SELECT COUNT(*) AS n FROM sys.dm_exec_sessions WHERE program_name = @app",
+ command => Bind(command, "@app", SqlPollServerFixture.DriverApplicationName),
+ reader => reader.GetInt32(reader.GetOrdinal("n")));
+ return counts[0];
+ }
+ catch (SqlException ex)
+ {
+ Assert.Skip(
+ "The configured login cannot read sys.dm_exec_sessions (VIEW SERVER STATE), so connection " +
+ $"pooling cannot be observed from here: {ex.Message}");
+ throw; // unreachable; Assert.Skip throws.
+ }
+ }
+
+ /// Binds one string catalog parameter, exactly as the browse session does.
+ private static void Bind(DbCommand command, string name, string value)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = name;
+ parameter.DbType = DbType.String;
+ parameter.Value = value;
+ command.Parameters.Add(parameter);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj
new file mode 100644
index 00000000..2a1e1c4c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj
@@ -0,0 +1,39 @@
+
+
+
+
+ $(NoWarn);OTOPCUA0001
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+