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