71881163df
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
569 lines
28 KiB
C#
569 lines
28 KiB
C#
using System.Data.Common;
|
|
using System.Globalization;
|
|
using System.Text.Json.Nodes;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// The <c>Sql</c> driver against a <b>real PostgreSQL</b>, over the <c>Npgsql</c> provider
|
|
/// <c>PostgresDialect</c> ships. The offline suites all run on SQLite, so three things are exercised
|
|
/// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing <c>LIMIT 1</c>
|
|
/// where T-SQL emits <c>SELECT TOP 1</c>), and <c>PostgresDialect</c>'s <c>information_schema</c>
|
|
/// catalog SQL — including the deliberate choice to derive the schema list from
|
|
/// <c>information_schema.tables</c> rather than <c>.schemata</c>, which only a
|
|
/// granted-but-not-owning principal can prove.
|
|
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="PostgresFixture"/> 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>Deliberately narrower than <see cref="SqlServerReadTests"/>.</b> The provider-agnostic
|
|
/// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned
|
|
/// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy
|
|
/// runtime rather than coverage. What is repeated here is exactly what a <em>dialect</em> can get
|
|
/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse
|
|
/// walk an operator authors through.</para>
|
|
/// </summary>
|
|
[Collection(PostgresCollection.Name)]
|
|
public sealed class PostgresReadTests
|
|
{
|
|
private readonly PostgresFixture _pg;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="PostgresReadTests"/> class.</summary>
|
|
/// <param name="pg">The seeded live-server fixture (collection-scoped).</param>
|
|
public PostgresReadTests(PostgresFixture pg) => _pg = pg;
|
|
|
|
// ---- lifecycle: liveness + catalog load ----
|
|
|
|
/// <summary>
|
|
/// Initialize's liveness check (<c>SELECT 1</c>) and its catalog load both succeed, and the driver
|
|
/// reports Healthy with the endpoint described credential-free.
|
|
/// <para><b>The catalog load is the interesting half, and it is asserted indirectly but
|
|
/// unambiguously.</b> <c>SqlCatalogLoader</c> throws when the connecting principal sees zero
|
|
/// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes
|
|
/// <c>BadNodeIdUnknown</c>. So a Good read (below) is only reachable when
|
|
/// <c>PostgresDialect.ListSchemasSql</c> returned the fixture schema for a role that merely holds
|
|
/// <c>USAGE</c> + <c>SELECT</c> on it — the provisioning that <c>information_schema.schemata</c>
|
|
/// would have answered empty for.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint()
|
|
{
|
|
SkipUnlessLive();
|
|
|
|
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.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 credential that reached the provider.
|
|
driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase);
|
|
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
|
|
|
|
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
|
}
|
|
|
|
// ---- key-value model ----
|
|
|
|
/// <summary>A seeded key-value row round-trips as a Good snapshot with its source timestamp.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue()
|
|
{
|
|
SkipUnlessLive();
|
|
|
|
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.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, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
|
|
// `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than
|
|
// reinterpreting it through the host's zone — the behaviour this asserts against a real column type.
|
|
snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc);
|
|
}
|
|
|
|
// ---- wide-row model: the Postgres-specific row-limit syntax ----
|
|
|
|
/// <summary>
|
|
/// The <c>topByTimestamp</c> selector resolves to the newest row.
|
|
/// <para><b>This is the Postgres-specific leg.</b> The planner spells the row limit through the
|
|
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>LIMIT 1</c> — where the SQL Server suite
|
|
/// exercises a leading <c>SELECT TOP 1</c>. The seed makes the newest row neither the
|
|
/// first-inserted nor the lowest-keyed one, so losing either the limit 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_realPostgres_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, CultureInfo.InvariantCulture)
|
|
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
|
|
}
|
|
|
|
// ---- integer key columns (STAB-24) ----
|
|
|
|
/// <summary>
|
|
/// A key-value tag whose key column is <c>integer</c> reads. Authored <c>keyValue</c> is text, and
|
|
/// Npgsql's default <c>text</c> bind has no <c>integer = text</c> operator — the read failed with a
|
|
/// provider error before <c>PostgresDialect.ConfigureParameter</c> sent string binds untyped.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_realPostgres_integerKeyColumnComparesAgainstAuthoredText()
|
|
{
|
|
SkipUnlessLive();
|
|
|
|
await using var driver = await StartAsync(Tag("Sql/IntKey/Tag101", new JsonObject
|
|
{
|
|
["driver"] = "Sql",
|
|
["model"] = "KeyValue",
|
|
["table"] = PostgresFixture.Qualified("int_tag_values"),
|
|
["keyColumn"] = "tag_id",
|
|
["keyValue"] = "101",
|
|
["valueColumn"] = PostgresFixture.ValueColumn,
|
|
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
|
}));
|
|
|
|
var snapshot = (await driver.ReadAsync(["Sql/IntKey/Tag101"], TestContext.Current.CancellationToken))
|
|
.ShouldHaveSingleItem();
|
|
|
|
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
|
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(77.5);
|
|
}
|
|
|
|
/// <summary>A wide-row <c>whereColumn</c> selector over the <c>integer station_id</c> column reads the matching row.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_realPostgres_integerWhereColumnSelectsTheMatchingStation()
|
|
{
|
|
SkipUnlessLive();
|
|
|
|
await using var driver = await StartAsync(Tag("Sql/Station7/OvenTemp", new JsonObject
|
|
{
|
|
["driver"] = "Sql",
|
|
["model"] = "WideRow",
|
|
["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
|
|
["columnName"] = "oven_temp",
|
|
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
|
["rowSelector"] = new JsonObject
|
|
{
|
|
["whereColumn"] = "station_id",
|
|
["whereValue"] = "7",
|
|
},
|
|
}));
|
|
|
|
var snapshot = (await driver.ReadAsync(["Sql/Station7/OvenTemp"], TestContext.Current.CancellationToken))
|
|
.ShouldHaveSingleItem();
|
|
|
|
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
|
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(180.5);
|
|
}
|
|
|
|
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
|
|
|
|
/// <summary>
|
|
/// An operator who authors <c>SQLPOLL.TAG_VALUES</c> against a catalog holding
|
|
/// <c>sqlpoll.tag_values</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
|
|
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
|
|
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
|
|
/// anything is quoted into SQL.
|
|
/// <para><b>Why this matters more on Postgres than it looks.</b> Postgres folds an unquoted
|
|
/// identifier to <em>lower</em> case, and <c>PostgresDialect.QuoteIdentifier</c> emits a
|
|
/// double-quoted — i.e. case-<em>sensitive</em> — name. Without the gate's substitution, the
|
|
/// authored <c>"TAG_VALUES"</c> would reach the server as a quoted identifier naming an object that
|
|
/// does not exist, and the tag would fail at query time instead of resolving. So this leg is not
|
|
/// cosmetic tolerance; it is the only thing making mixed-case authoring work at all.</para>
|
|
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog columns differing solely in
|
|
/// case — which cannot be produced from this seed and is pinned offline in the unit suites.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling()
|
|
{
|
|
SkipUnlessLive();
|
|
|
|
var upper = Tag("Sql/Line1/Speed", new JsonObject
|
|
{
|
|
["driver"] = "Sql",
|
|
["model"] = "KeyValue",
|
|
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(),
|
|
["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(),
|
|
["keyValue"] = PostgresFixture.PresentKey,
|
|
["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(),
|
|
["timestampColumn"] = PostgresFixture.TimestampColumn.ToUpperInvariant(),
|
|
});
|
|
|
|
await using var driver = await StartAsync(upper);
|
|
|
|
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
|
|
.ShouldHaveSingleItem();
|
|
|
|
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
|
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
|
|
}
|
|
|
|
// ---- browse session: the AdminUI's schema → table → column walk ----
|
|
|
|
/// <summary>
|
|
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
|
|
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
|
|
/// over a real database, and the leaf's mapped driver type is the one
|
|
/// <c>PostgresDialect.MapColumnType</c> promises for <c>double precision</c>.
|
|
/// <para>Driven through <see cref="SqlDriverBrowser"/> rather than the internal session, because
|
|
/// that is the seam the AdminUI actually calls: it also proves the provider gate admits
|
|
/// <c>Postgres</c> and that the dialect's <em>quoted</em> catalog aliases
|
|
/// (<c>AS "TABLE_NAME"</c>) survive Postgres' lower-casing of unquoted output columns — the
|
|
/// browse session reads every catalog column by name, so an unquoted alias would fold to
|
|
/// <c>table_name</c> and the walk would throw on <c>GetOrdinal</c> rather than return an empty
|
|
/// list.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf()
|
|
{
|
|
SkipUnlessLive();
|
|
|
|
var config = new JsonObject
|
|
{
|
|
["provider"] = nameof(SqlProvider.Postgres),
|
|
["connectionString"] = _pg.ConnectionString,
|
|
}.ToJsonString();
|
|
|
|
await using var session = await new SqlDriverBrowser()
|
|
.OpenAsync(config, TestContext.Current.CancellationToken);
|
|
|
|
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
|
|
var schema = schemas.ShouldHaveSingleItem(
|
|
"the fixture login can SELECT in exactly one schema, so exactly one is browsable");
|
|
schema.DisplayName.ShouldBe(PostgresFixture.Schema);
|
|
|
|
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
|
|
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
|
|
// return, which is the only useful diagnostic when a browse walk comes back short.
|
|
tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable);
|
|
var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable);
|
|
|
|
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
|
|
columns.Select(c => c.DisplayName).ShouldBe(
|
|
[PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]);
|
|
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
|
|
|
|
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
|
|
// column, or the commit falls through to a generic blob the typed editor cannot read.
|
|
var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn);
|
|
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
|
|
.ShouldHaveSingleItem();
|
|
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision
|
|
|
|
var address = attribute.AddressFields.ShouldNotBeNull();
|
|
address["schema"].ShouldBe(PostgresFixture.Schema);
|
|
address["table"].ShouldBe(PostgresFixture.KeyValueTable);
|
|
address["columnName"].ShouldBe(PostgresFixture.ValueColumn);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
|
private void SkipUnlessLive()
|
|
{
|
|
if (_pg.SkipReason is { } reason) Assert.Skip(reason);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds and initializes a driver over the seeded database, with no injected factory — which is
|
|
/// precisely what makes this suite exercise <c>NpgsqlFactory.Instance</c>, the provider the dialect
|
|
/// ships.
|
|
/// </summary>
|
|
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
|
|
{
|
|
var options = new SqlDriverOptions
|
|
{
|
|
RawTags = tags,
|
|
CommandTimeout = TimeSpan.FromSeconds(10),
|
|
OperationTimeout = TimeSpan.FromSeconds(15),
|
|
};
|
|
|
|
var driver = new SqlDriver(
|
|
options,
|
|
driverInstanceId: "sql-postgres-integration",
|
|
dialect: new PostgresDialect(),
|
|
connectionString: _pg.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"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable),
|
|
["keyColumn"] = PostgresFixture.KeyColumn,
|
|
["keyValue"] = key,
|
|
["valueColumn"] = PostgresFixture.ValueColumn,
|
|
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
|
});
|
|
|
|
/// <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"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
|
|
["columnName"] = column,
|
|
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
|
["rowSelector"] = new JsonObject
|
|
{
|
|
["topByTimestamp"] = PostgresFixture.TimestampColumn,
|
|
},
|
|
});
|
|
|
|
/// <summary>
|
|
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
|
|
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
|
|
/// <see cref="SqlServerReadTests"/> gives: a hand-written literal that loses a brace produces a
|
|
/// config the parser silently rejects, which surfaces as a plausible-looking driver failure.
|
|
/// </summary>
|
|
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
|
|
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A real, seeded <b>PostgreSQL</b> database standing in for the source the <c>Sql</c> driver polls —
|
|
/// the Postgres counterpart of <see cref="SqlPollServerFixture"/>.
|
|
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
|
|
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both 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.</para>
|
|
/// <para><b>This fixture does not seed — the compose stack does</b>
|
|
/// (<c>Docker/postgres/docker-compose.yml</c> + <c>seed-pg.sql</c>), and that is deliberate rather
|
|
/// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is
|
|
/// <em>not</em>: the connecting principal holds <c>USAGE</c> + <c>SELECT</c> and could not create
|
|
/// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a
|
|
/// superuser, and the granted-not-owning provisioning that
|
|
/// <c>PostgresDialect.ListSchemasSql</c> exists for would never be exercised.</para>
|
|
/// </summary>
|
|
public sealed class PostgresFixture : IAsyncLifetime
|
|
{
|
|
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:15432</c>). Absent ⇒ skip.</summary>
|
|
public const string EndpointEnvVar = "SQL_PG_ENDPOINT";
|
|
|
|
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the seed grants.</summary>
|
|
public const string UserEnvVar = "SQL_PG_USER";
|
|
|
|
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
|
|
public const string PasswordEnvVar = "SQL_PG_PASSWORD";
|
|
|
|
/// <summary>The unprivileged login the seed creates and grants USAGE + SELECT to.</summary>
|
|
public const string DefaultUser = "otopcua";
|
|
|
|
/// <summary>Postgres' default port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
|
|
public const int DefaultPort = 5432;
|
|
|
|
/// <summary>The database the compose stack creates (<c>POSTGRES_DB</c>) and the seed targets.</summary>
|
|
public const string FixtureDatabase = "sqlpollfixture";
|
|
|
|
/// <summary>
|
|
/// The schema every seeded object lives in — <b>not</b> <c>public</c>, so every authored
|
|
/// <c>table</c> is a two-part name and the planner's qualified quoting is exercised, and so the
|
|
/// seed's drop-and-recreate is bounded to objects this fixture owns.
|
|
/// </summary>
|
|
public const string Schema = "sqlpoll";
|
|
|
|
/// <summary>The <c>Application Name</c> the driver's connections carry, for server-side attribution.</summary>
|
|
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg";
|
|
|
|
/// <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 varchar(128), num_value double precision NULL, sample_ts timestamp(3)</c>.</summary>
|
|
public const string KeyValueTable = "tag_values";
|
|
|
|
/// <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>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 integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3)</c>.</summary>
|
|
public const string WideRowTable = "latest_status";
|
|
|
|
/// <summary>
|
|
/// The <c>oven_temp</c> of the row holding the <b>newest</b> <c>sample_ts</c> — what a
|
|
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
|
|
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its <c>LIMIT 1</c> picks a
|
|
/// different value and the test says so.
|
|
/// </summary>
|
|
public const double NewestStationOvenTemp = 210.25;
|
|
|
|
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
|
|
public string? SkipReason { get; private set; }
|
|
|
|
/// <summary>The connection string to hand the code under test. 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>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>
|
|
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
|
|
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or
|
|
/// seeded here (the compose stack owns that).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
if (ResolveConnectionString() is not { } resolved)
|
|
{
|
|
SkipReason =
|
|
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
|
|
$"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " +
|
|
"fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " +
|
|
"without these.";
|
|
return;
|
|
}
|
|
|
|
ConnectionString = resolved;
|
|
|
|
var dialect = new PostgresDialect();
|
|
try
|
|
{
|
|
var connection = dialect.Factory.CreateConnection()
|
|
?? throw new InvalidOperationException("Npgsql returned no connection object.");
|
|
await using (connection.ConfigureAwait(false))
|
|
{
|
|
connection.ConnectionString = ConnectionString;
|
|
await connection.OpenAsync().ConfigureAwait(false);
|
|
|
|
var command = connection.CreateCommand();
|
|
await using (command.ConfigureAwait(false))
|
|
{
|
|
command.CommandText = dialect.LivenessSql;
|
|
await command.ExecuteScalarAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SkipReason =
|
|
$"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
|
|
$"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" +
|
|
$"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip.";
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// Reads the gate and composes the connection string <b>through the dialect's own provider
|
|
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via
|
|
/// the abstract <see cref="DbConnectionStringBuilder"/> indexer.
|
|
/// <para>That indirection is deliberate: it keeps this project free of a direct Npgsql
|
|
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
|
|
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
|
|
/// <c>Host</c>, <c>Port</c>, <c>Database</c>, <c>Username</c>, <c>Password</c>, <c>Timeout</c>,
|
|
/// <c>Application Name</c> — was verified to be accepted through that indexer, so no
|
|
/// provider-specific property is needed.</para>
|
|
/// </summary>
|
|
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
|
|
private string? ResolveConnectionString()
|
|
{
|
|
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, which is how accounts get locked out on a server that is not a throwaway.
|
|
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
|
|
|
|
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
|
|
? named
|
|
: DefaultUser;
|
|
|
|
var (host, port) = SplitEndpoint(endpoint, DefaultPort);
|
|
Server = $"{host}:{port}";
|
|
|
|
var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder()
|
|
?? throw new InvalidOperationException("Npgsql returned no connection-string builder.");
|
|
builder["Host"] = host;
|
|
builder["Port"] = port;
|
|
builder["Database"] = FixtureDatabase;
|
|
builder["Username"] = user;
|
|
builder["Password"] = password;
|
|
builder["Timeout"] = ProbeConnectTimeoutSeconds;
|
|
builder["Application Name"] = DriverApplicationName;
|
|
return builder.ConnectionString;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits a <c>host:port</c> endpoint, defaulting the port when absent or unparseable. Split on the
|
|
/// LAST colon so a bracketed IPv6 literal keeps its own colons.
|
|
/// </summary>
|
|
/// <param name="endpoint">The authored endpoint.</param>
|
|
/// <param name="defaultPort">The port to use when none is carried.</param>
|
|
/// <returns>The host and port.</returns>
|
|
internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort)
|
|
{
|
|
var separator = endpoint.LastIndexOf(':');
|
|
if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort);
|
|
|
|
var host = endpoint[..separator];
|
|
return int.TryParse(
|
|
endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
|
|
? (host, port)
|
|
: (endpoint, defaultPort);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collection definition so the gate is resolved once per test session rather than once per test
|
|
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
|
|
/// </summary>
|
|
[CollectionDefinition(Name)]
|
|
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
|
|
{
|
|
/// <summary>The collection name test classes reference.</summary>
|
|
public const string Name = "SqlPostgresServer";
|
|
}
|