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;
///
/// The Sql driver against a real Oracle Database, over the fully managed ODP.NET provider
/// (Oracle.ManagedDataAccess.Core) OracleDialect ships. Four things are exercised nowhere
/// else in this driver's estate: the ODP.NET ADO.NET path, OracleCommand.BindByName against a
/// real multi-parameter statement, the ALL_* catalog views that stand in for the
/// INFORMATION_SCHEMA Oracle does not have, and FETCH FIRST 1 ROWS ONLY row limiting.
/// BindByName is the sharpest edge, and the browse leg below is what proves it live.
/// ODP.NET binds by position by default, and OracleDialect.ListTablesSql names
/// :schema twice (once per branch of its UNION ALL) while binding it once — under
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
/// real OracleCommand; this asserts the statement it protects actually runs.
/// Env-gated; skipping is the normal outcome. See for the gate.
/// With it unset nothing here opens a socket, so the suite is instant and green offline.
/// Deliberately narrower than , for the reason
/// gives: the provider-agnostic read semantics are pinned once against
/// SQL Server and by the SQLite suites, and only what a dialect can get wrong is repeated
/// per provider.
///
[Collection(OracleCollection.Name)]
public sealed class OracleReadTests
{
private readonly OracleFixture _ora;
/// Initializes a new instance of the class.
/// The seeded live-server fixture (collection-scoped).
public OracleReadTests(OracleFixture ora) => _ora = ora;
// ---- lifecycle: liveness + catalog load ----
///
/// Initialize's liveness check — SELECT 1 FROM DUAL, not the bare SELECT 1 every other
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
/// described credential-free.
/// The catalog load is asserted indirectly but unambiguously: SqlCatalogLoader throws
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
/// BadNodeIdUnknown instead of a value — so the Good read below is only reachable once
/// ALL_USERS / ALL_TABLES / ALL_TAB_COLUMNS have all answered the shape the
/// loader expects.
///
[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 ----
///
/// A seeded key-value row round-trips as a Good snapshot with its source timestamp.
/// The value column is a plain NUMBER — Oracle's single numeric family — which
/// OracleDialect.MapColumnType folds to . 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 "type".
///
[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);
}
///
/// Two keys on the same table fold into ONE query — … WHERE "TAG_NAME" IN (:k0, :k1) — and are
/// sliced back to the right tags.
/// This is the live falsification of positional binding. With
/// OracleCommand.BindByName 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
/// successful query over the wrong rows — 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.
///
[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 ----
///
/// The topByTimestamp selector resolves to the newest row.
/// This is the Oracle-specific leg. The planner spells the row limit through the
/// dialect's SingleRowLimitSuffix — a trailing FETCH FIRST 1 ROWS ONLY (the 12.1+
/// ANSI clause), deliberately not WHERE ROWNUM = 1, which is assigned before
/// ORDER BY 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.
///
[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) ----
///
/// An operator who authors otopcua.tag_values against a catalog holding
/// OTOPCUA.TAG_VALUES still reads. Pinned as accepted, not rejected — that is what
/// SqlCatalog.TryMatch actually does: an exact ordinal hit wins, and failing that a
/// unique case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// On Oracle this is not tolerance, it is the only thing that works. Oracle upper-cases
/// an unquoted identifier, so the catalog holds TAG_VALUES; but
/// OracleDialect.QuoteIdentifier emits a double-quoted — i.e. case-sensitive — name,
/// and "tag_values" and "TAG_VALUES" 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.
/// The gate refuses only an ambiguous 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.
///
[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 ----
///
/// The AdminUI picker's walk — RootAsync (schemas) → ExpandAsync (tables) →
/// ExpandAsync (columns) → AttributesAsync (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// OracleDialect.MapColumnType promises for NUMBER.
/// The table expand is the BindByName proof. It runs
/// OracleDialect.ListTablesSql, whose UNION ALL names :schema 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
/// ConfigureCommand ran at every command site, not just in the unit test.
/// Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
/// ALL_USERS lists every user in the instance (Oracle equates a schema with a user), so the
/// seeded schema is one of many.
///
[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 ----
/// Skips the calling test when the live-server gate is not configured or not reachable.
private void SkipUnlessLive()
{
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
}
///
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
/// precisely what makes this suite exercise OracleClientFactory.Instance, the provider the
/// dialect ships.
///
private async Task 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;
}
/// A key-value tag over the seeded EAV table, authored in the catalog's own spelling.
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,
});
/// A wide-row tag selected by newest timestamp.
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,
},
});
///
/// Wraps an authored TagConfig object as a raw tag entry. Composed as a
/// rather than as an interpolated string literal for the reason
/// gives.
///
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
///
/// A real, seeded Oracle Database schema standing in for the source the Sql driver polls —
/// the Oracle counterpart of .
/// Env-gated, and the gate is checked before any socket is touched. This fixture opens
/// nothing unless and are both set; absent, it
/// records and every test calls Assert.Skip.
/// This fixture does not seed — the compose stack does
/// (Docker/oracle/docker-compose.yml + seed-oracle.sql). Oracle equates a schema with a
/// user, so the seed runs as the same otopcua principal the suite connects as and its objects
/// land in the OTOPCUA schema.
/// Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
/// style choice. 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.
///
public sealed class OracleFixture : IAsyncLifetime
{
/// Listener endpoint in host:port form (e.g. 10.100.0.35:11521). Absent ⇒ skip.
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
/// The login. Defaults to , the user the compose stack creates.
public const string UserEnvVar = "SQL_ORACLE_USER";
/// The password. Required — never defaulted (see ).
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
///
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
/// , the pluggable database the gvenzl/oracle-free image creates.
///
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
/// The application user the compose stack creates (APP_USER).
public const string DefaultUser = "otopcua";
/// The image's pluggable-database service name.
public const string DefaultService = "FREEPDB1";
/// Oracle's default listener port, used when carries no port.
public const int DefaultPort = 1521;
/// The schema the seed's objects live in — Oracle folds the unquoted user name up.
public const string Schema = "OTOPCUA";
/// Seconds a connect attempt may take before the fixture calls the server unreachable.
private const int ProbeConnectTimeoutSeconds = 10;
// ---- key-value (EAV) source ----
/// The key-value table: TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3).
public const string KeyValueTable = "TAG_VALUES";
/// The key-value table's key column.
public const string KeyColumn = "TAG_NAME";
/// The key-value table's value column.
public const string ValueColumn = "NUM_VALUE";
/// The source-timestamp column carried by every seeded table.
public const string TimestampColumn = "SAMPLE_TS";
/// A key whose row exists and whose value cell holds .
public const string PresentKey = "Line1.Speed";
/// The value seeded for .
public const double PresentValue = 42.0;
/// A second present key, so a group can legitimately fold more than one member.
public const string SecondPresentKey = "Line1.Pressure";
/// The value seeded for — different from
/// , so a positional-binding swap is visible.
public const double SecondPresentValue = 3.5;
/// The UTC source timestamp seeded for .
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// The wide-row table: STATION_ID NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3).
public const string WideRowTable = "LATEST_STATUS";
/// The wide-row table's value column the topByTimestamp leg reads.
public const string OvenTempColumn = "OVEN_TEMP";
///
/// The of the row holding the newest SAMPLE_TS — what a
/// topByTimestamp selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its ORDER BY … DESC or its
/// FETCH FIRST 1 ROWS ONLY picks a different value and the test says so.
///
public const double NewestStationOvenTemp = 210.25;
/// Why the suite is skipping, or when the server is reachable.
public string? SkipReason { get; private set; }
/// The connection string to hand the code under test. Empty while is set.
public string ConnectionString { get; private set; } = string.Empty;
/// The resolved service name, for the endpoint-description assertion.
public string ServiceName { get; private set; } = DefaultService;
/// The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials.
public string Server { get; private set; } = string.Empty;
/// Two-part name of a seeded object, in the form an authored tag's table carries.
/// The bare table or view name.
/// The schema.table name.
public static string Qualified(string table) => $"{Schema}.{table}";
///
/// Resolves the gate and probes the server with the dialect's own LivenessSql. A failure to
/// connect is the offline case and becomes a ; nothing is created or seeded
/// here (the compose stack owns that).
///
/// A task that represents the asynchronous operation.
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.";
}
}
/// Holds no long-lived connection, so there is nothing to tear down.
/// A completed task.
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
///
/// Reads the gate and composes the connection string through the dialect's own provider
/// factory (), setting keys via the
/// abstract indexer.
/// That indirection is deliberate: it keeps this project free of a direct ODP.NET
/// PackageReference, 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 —
/// Data Source, User Id, Password, Connection Timeout — was verified to
/// be accepted through that indexer, so no provider-specific property is needed.
/// The data source is an EZ-Connect descriptor (host:port/service), which needs
/// no tnsnames.ora — deliberate, since the managed provider is meant to run with no client
/// install and no Oracle configuration files anywhere near the test host.
///
/// The connection string, or — touching no socket — when unconfigured.
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;
}
}
///
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as .
///
[CollectionDefinition(Name)]
public sealed class OracleCollection : ICollectionFixture
{
/// The collection name test classes reference.
public const string Name = "SqlOracleServer";
}