Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs
T

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";
}