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