Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OracleReadTests.cs
T
Joseph Doherty 9675823cd6 test(sql): env-gated Postgres + Oracle integration suites
Two disposable docker fixtures (postgres:17-alpine on 15432, gvenzl/oracle-free:
23-slim on 11521) shaped like the existing mssql one — project label, distinct
container name, healthcheck, one-shot seed service — plus a live suite per
provider, gated on SQL_PG_* / SQL_ORACLE_* and skipping clean offline.

Each suite covers what a DIALECT can get wrong, rather than re-running the
provider-agnostic read semantics already pinned against SQL Server and SQLite:
liveness + catalog load, a KeyValue read, the provider's own row-limit syntax via
topByTimestamp (LIMIT 1 / FETCH FIRST 1 ROWS ONLY, where T-SQL emits TOP 1),
identifier case-folding through the design §8.1 catalog gate, and the AdminUI
browse walk driven through SqlDriverBrowser.

Two seed decisions are load-bearing rather than scaffolding:

- The Postgres schema is owned by a role the suite is NOT; the connecting login
  holds USAGE + SELECT only. That is the exact provisioning PostgresDialect's
  ListSchemasSql exists for (information_schema.schemata lists only schemas the
  role OWNS, so a granted-not-owning poller would see none and every tag would be
  rejected). A fixture that owned its own schema would pass either way.
- Every identifier in both seeds is created UNQUOTED, so the catalogs hold
  lower-case (Postgres) and UPPER-case (Oracle) spellings. The case-folding legs
  author the opposite case and pin SqlCatalog.TryMatch's REAL contract — exact
  ordinal first, then a UNIQUE case-insensitive match whose catalog spelling is
  substituted — which on both providers is what makes mixed-case authoring work
  at all, since both dialects emit case-sensitive double-quoted identifiers.

Connection strings are composed through dialect.Factory.CreateConnectionString-
Builder() and the abstract DbConnectionStringBuilder indexer, so the csproj keeps
its no-direct-provider-reference discipline; every key used was verified accepted
through that indexer. The csproj gains only Driver.Sql.Browser, for the browse leg.

Live-run evidence (local OrbStack, both images arm64): Postgres 5/5 green;
Oracle 6/6 green after the ORA-01745 dialect fix this suite found. Offline the
whole project is 33 skipped / 0 failed.
2026-08-05 07:15:43 -04:00

546 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 Oracle Database</b>, over the fully managed ODP.NET provider
/// (<c>Oracle.ManagedDataAccess.Core</c>) <c>OracleDialect</c> ships. Four things are exercised nowhere
/// else in this driver's estate: the ODP.NET ADO.NET path, <c>OracleCommand.BindByName</c> against a
/// real multi-parameter statement, the <c>ALL_*</c> catalog views that stand in for the
/// <c>INFORMATION_SCHEMA</c> Oracle does not have, and <c>FETCH FIRST 1 ROWS ONLY</c> row limiting.
/// <para><b><c>BindByName</c> is the sharpest edge, and the browse leg below is what proves it live.</b>
/// ODP.NET binds by <em>position</em> by default, and <c>OracleDialect.ListTablesSql</c> names
/// <c>:schema</c> twice (once per branch of its <c>UNION ALL</c>) while binding it once — under
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
/// real <c>OracleCommand</c>; this asserts the statement it protects actually runs.</para>
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="OracleFixture"/> for the gate.
/// With it unset nothing here opens a socket, so the suite is instant and green offline.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/></b>, for the reason
/// <see cref="PostgresReadTests"/> gives: the provider-agnostic read semantics are pinned once against
/// SQL Server and by the SQLite suites, and only what a <em>dialect</em> can get wrong is repeated
/// per provider.</para>
/// </summary>
[Collection(OracleCollection.Name)]
public sealed class OracleReadTests
{
private readonly OracleFixture _ora;
/// <summary>Initializes a new instance of the <see cref="OracleReadTests"/> class.</summary>
/// <param name="ora">The seeded live-server fixture (collection-scoped).</param>
public OracleReadTests(OracleFixture ora) => _ora = ora;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check — <c>SELECT 1 FROM DUAL</c>, not the bare <c>SELECT 1</c> every other
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
/// described credential-free.
/// <para>The catalog load is asserted indirectly but unambiguously: <c>SqlCatalogLoader</c> throws
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
/// <c>BadNodeIdUnknown</c> instead of a value — so the Good read below is only reachable once
/// <c>ALL_USERS</c> / <c>ALL_TABLES</c> / <c>ALL_TAB_COLUMNS</c> have all answered the shape the
/// loader expects.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realOracle_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.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. Oracle's connection string
// names the database inside Data Source (host:port/service), so the service is what must survive —
// and the credential must not.
driver.Endpoint.ShouldContain(_ora.ServiceName);
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.
/// <para>The value column is a plain <c>NUMBER</c> — Oracle's single numeric family — which
/// <c>OracleDialect.MapColumnType</c> folds to <see cref="DriverDataType.Float64"/>. This is the
/// live confirmation of that documented under-specification: an integral-looking seeded value comes
/// back as a floating-point node, and the escape hatch for an operator who needs otherwise is an
/// explicit authored <c>"type"</c>.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.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(OracleFixture.PresentValue);
snapshot.SourceTimestampUtc.ShouldBe(OracleFixture.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.
/// <para><b>This is the live falsification of positional binding.</b> With
/// <c>OracleCommand.BindByName</c> unset, ODP.NET fills the markers in statement order from the
/// parameter collection regardless of their names, and the failure mode is not an exception but a
/// <em>successful query over the wrong rows</em> — one tag's value published onto another tag's node.
/// The two seeded values differ, so a swap fails the assertion rather than passing silently.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
{
SkipUnlessLive();
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey),
KeyValue("Sql/Line1/Pressure", OracleFixture.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, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.SecondPresentValue);
}
// ---- wide-row model: the Oracle-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Oracle-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>FETCH FIRST 1 ROWS ONLY</c> (the 12.1+
/// ANSI clause), deliberately not <c>WHERE ROWNUM = 1</c>, which is assigned before
/// <c>ORDER BY</c> runs and would take an arbitrary row and then sort it. The seed makes the newest
/// row neither the first-inserted nor the lowest-keyed one, so that exact mistake returns a
/// different, wrong value here rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", OracleFixture.OvenTempColumn));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against the real ALL_* views) ----
/// <summary>
/// An operator who authors <c>otopcua.tag_values</c> against a catalog holding
/// <c>OTOPCUA.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>On Oracle this is not tolerance, it is the only thing that works.</b> Oracle upper-cases
/// an unquoted identifier, so the catalog holds <c>TAG_VALUES</c>; but
/// <c>OracleDialect.QuoteIdentifier</c> emits a double-quoted — i.e. case-<em>sensitive</em> — name,
/// and <c>"tag_values"</c> and <c>"TAG_VALUES"</c> are different objects to Oracle. Without the
/// gate's substitution the authored lower-case spelling would reach the server naming an object that
/// does not exist. Every AdminUI-authored tag would be affected, since nobody types SHOUTING
/// identifiers by hand.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog names differing solely in
/// case, which Oracle can only produce from quoted DDL — and that cannot arise from this seed; it is
/// pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realOracle_foldsALowerCasedAuthoredNameOntoTheUpperCaseCatalogSpelling()
{
SkipUnlessLive();
var lower = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable).ToLowerInvariant(),
["keyColumn"] = OracleFixture.KeyColumn.ToLowerInvariant(),
["keyValue"] = OracleFixture.PresentKey,
["valueColumn"] = OracleFixture.ValueColumn.ToLowerInvariant(),
["timestampColumn"] = OracleFixture.TimestampColumn.ToLowerInvariant(),
});
await using var driver = await StartAsync(lower);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.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>OracleDialect.MapColumnType</c> promises for <c>NUMBER</c>.
/// <para><b>The table expand is the <c>BindByName</c> proof.</b> It runs
/// <c>OracleDialect.ListTablesSql</c>, whose <c>UNION ALL</c> names <c>:schema</c> twice against a
/// single bound parameter — a statement that cannot execute under ODP.NET's default positional
/// binding. Reaching a non-empty table list here is therefore evidence that
/// <c>ConfigureCommand</c> ran at every command site, not just in the unit test.</para>
/// <para>Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
/// <c>ALL_USERS</c> lists every user in the instance (Oracle equates a schema with a user), so the
/// seeded schema is one of many.</para>
/// </summary>
[Fact]
public async Task Browse_realOracle_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Oracle),
["connectionString"] = _ora.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, 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.
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
schemas.Select(s => s.DisplayName).ShouldContain(OracleFixture.Schema);
var schema = schemas.First(s => s.DisplayName == OracleFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
tables.Select(t => t.DisplayName).ShouldContain(OracleFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == OracleFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[OracleFixture.KeyColumn, OracleFixture.ValueColumn, OracleFixture.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 == OracleFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // NUMBER
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(OracleFixture.Schema);
address["table"].ShouldBe(OracleFixture.KeyValueTable);
address["columnName"].ShouldBe(OracleFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
/// precisely what makes this suite exercise <c>OracleClientFactory.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(15),
OperationTimeout = TimeSpan.FromSeconds(20),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-oracle-integration",
dialect: new OracleDialect(),
connectionString: _ora.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, authored in the catalog's own spelling.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable),
["keyColumn"] = OracleFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = OracleFixture.ValueColumn,
["timestampColumn"] = OracleFixture.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"] = OracleFixture.Qualified(OracleFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = OracleFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = OracleFixture.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.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>Oracle Database</b> schema standing in for the source the <c>Sql</c> driver polls —
/// the Oracle 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>.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/oracle/docker-compose.yml</c> + <c>seed-oracle.sql</c>). Oracle equates a schema with a
/// user, so the seed runs as the same <c>otopcua</c> principal the suite connects as and its objects
/// land in the <c>OTOPCUA</c> schema.</para>
/// <para><b>Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
/// style choice.</b> The seed's DDL is unquoted, so Oracle folds it up; the case-folding leg is the one
/// place that deliberately authors the lower-case form an operator would actually type.</para>
/// </summary>
public sealed class OracleFixture : IAsyncLifetime
{
/// <summary>Listener endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:11521</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the compose stack creates.</summary>
public const string UserEnvVar = "SQL_ORACLE_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
/// <summary>
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
/// <see cref="DefaultService"/>, the pluggable database the <c>gvenzl/oracle-free</c> image creates.
/// </summary>
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
/// <summary>The application user the compose stack creates (<c>APP_USER</c>).</summary>
public const string DefaultUser = "otopcua";
/// <summary>The image's pluggable-database service name.</summary>
public const string DefaultService = "FREEPDB1";
/// <summary>Oracle's default listener port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 1521;
/// <summary>The schema the seed's objects live in — Oracle folds the unquoted user name up.</summary>
public const string Schema = "OTOPCUA";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 10;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, 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>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"/> — different from
/// <see cref="PresentValue"/>, so a positional-binding swap is visible.</summary>
public const double SecondPresentValue = 3.5;
/// <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 NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string WideRowTable = "LATEST_STATUS";
/// <summary>The wide-row table's value column the <c>topByTimestamp</c> leg reads.</summary>
public const string OvenTempColumn = "OVEN_TEMP";
/// <summary>
/// The <see cref="OvenTempColumn"/> 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>FETCH FIRST 1 ROWS ONLY</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 resolved service name, for the endpoint-description assertion.</summary>
public string ServiceName { get; private set; } = DefaultService;
/// <summary>The listener 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}', and {ServiceEnvVar}, default '{DefaultService}') to run the " +
"Sql driver's live Oracle suite. Bring the fixture up from " +
"Docker/oracle/docker-compose.yml first. No connection is attempted without these.";
return;
}
ConnectionString = resolved;
var dialect = new OracleDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("ODP.NET 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))
{
dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"Oracle at '{Server}' (service '{ServiceName}') was not reachable within " +
$"{ProbeConnectTimeoutSeconds}s ({ex.GetType().Name}: {ex.Message}). Check " +
$"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar}/{ServiceEnvVar} and that the fixture " +
"stack is up (its first boot takes minutes), 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 ODP.NET
/// <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>Data Source</c>, <c>User Id</c>, <c>Password</c>, <c>Connection Timeout</c> — was verified to
/// be accepted through that indexer, so no provider-specific property is needed.</para>
/// <para>The data source is an <b>EZ-Connect</b> descriptor (<c>host:port/service</c>), which needs
/// no <c>tnsnames.ora</c> — deliberate, since the managed provider is meant to run with no client
/// install and no Oracle configuration files anywhere near the test host.</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, and Oracle locks an account after a handful of those.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
ServiceName = Environment.GetEnvironmentVariable(ServiceEnvVar) is { Length: > 0 } service
? service
: DefaultService;
var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new OracleDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("ODP.NET returned no connection-string builder.");
builder["Data Source"] = $"{host}:{port}/{ServiceName}";
builder["User Id"] = user;
builder["Password"] = password;
builder["Connection Timeout"] = ProbeConnectTimeoutSeconds;
return builder.ConnectionString;
}
}
/// <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 OracleCollection : ICollectionFixture<OracleFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlOracleServer";
}