02f2dafe2a
Its doc said it pinned "the real provider's cancellation path", but the token is cancelled before ReadAsync so the OCE is raised at the reader's SemaphoreSlim.WaitAsync gate, before any SqlConnection opens — it never exercises Microsoft.Data.SqlClient's in-flight command cancellation. Softened (option (a)) to state accurately what it proves: an already- cancelled token propagates as OCE and is never swallowed into a Bad snapshot or an unreachable-database verdict. The in-flight/deadline path is covered by SqlBlackholeTimeoutTests. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
672 lines
32 KiB
C#
672 lines
32 KiB
C#
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.
|
|
/// <para><b>What this proves, precisely.</b> The token is <em>already</em> cancelled before
|
|
/// <c>ReadAsync</c> is called, so the cancellation is observed at the reader's first checkpoint —
|
|
/// <c>SemaphoreSlim.WaitAsync(_, token)</c> — <b>before any <c>SqlConnection</c> opens</b>. It
|
|
/// therefore does <b>not</b> exercise <c>Microsoft.Data.SqlClient</c>'s in-flight cancellation of a
|
|
/// running command; that is left to the blackhole gate's deadline path
|
|
/// (<c>SqlBlackholeTimeoutTests</c>). What it pins is the driver-shell contract that a cancelled
|
|
/// token surfaces as an <see cref="OperationCanceledException"/> and is <b>never</b> swallowed into
|
|
/// a Bad snapshot or misread as an unreachable-database verdict — an offline-equivalent guarantee,
|
|
/// run here over the real provider only to keep it beside its siblings.</para>
|
|
/// </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);
|
|
}
|
|
}
|