using System.Data; 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.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; /// /// Walks over a real, seeded SQLite catalog through the /// — the only non-SQL-Server dialect in the tree, and therefore the control /// that proves the session runs the dialect's catalog SQL rather than INFORMATION_SCHEMA /// with a quoting helper bolted on. /// public sealed class SqlBrowseSessionTests { private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId; [Fact] public async Task RootAsync_yieldsSchemaFolders() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var roots = await session.RootAsync(CancellationToken.None); roots.Count.ShouldBe(1); roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema); roots[0].NodeId.ShouldBe(SchemaNodeId); roots[0].Kind.ShouldBe(BrowseNodeKind.Folder); roots[0].HasChildrenHint.ShouldBeTrue(); } [Fact] public async Task ExpandSchema_yieldsTablesAndViewsAsFolders() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint); children.ShouldContain(n => n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable)); // The view is listed beside the tables, and is labelled so the operator can tell them apart. var view = children .Where(n => n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName)) .ShouldHaveSingleItem(); view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName); view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName); } /// The plan's headline case: table expand yields columns as committable leaves. [Fact] public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); // Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --", // so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too. var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn && c.Kind == BrowseNodeKind.Leaf && !c.HasChildrenHint); var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn); var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None); var attribute = attributes.ShouldHaveSingleItem(); attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn); attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); attribute.IsArray.ShouldBeFalse(); attribute.SecurityClass.ShouldBe("ViewOnly"); attribute.IsAlarm.ShouldBeFalse(); } [Fact] public async Task AttributesAsync_mapsTextColumnToString() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn); var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// An exotic declared type must fall back to via /// MapColumnType rather than crash the browse — a table with one geography column must /// still render. /// [Fact] public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable); var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None); columns.Count.ShouldBe(2); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn); var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// The end-to-end awkward-identifier walk: a table named a.b|c holding a column named /// x|y. Both characters are the ones a naive schema.table|column NodeId splits on, so /// this is the case that proves the encoding, not just the codec unit test. /// [Fact] public async Task AwkwardIdentifiers_surviveTheFullWalk() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); var awkward = tables .Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable) .ShouldHaveSingleItem(); var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None); columns.Select(c => c.DisplayName) .ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 }, ignoreOrder: true); var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn); var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId); parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable); parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn); var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); } /// /// A table whose name is an injection payload. If any catalog query concatenated the name /// instead of binding @table, expanding it would drop TagValues. /// [Fact] public async Task HostileTableName_isBound_notConcatenated() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable); var columns = await session.ExpandAsync(nodeId, CancellationToken.None); // Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it // before anything about the columns, or a splice that returns no rows reds on the wrong line and the // test stops proving the payload was inert. db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn); var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None); db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32)); } /// A hostile schema name takes the same bound path at the table level. [Fact] public async Task HostileSchemaName_isBound_notConcatenated() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --"); var tables = await session.ExpandAsync(nodeId, CancellationToken.None); // SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing. tables.ShouldBeEmpty(); db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); } /// /// A table node whose table is gone (dropped between browse and expand) yields an empty column list. /// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less /// table, and an empty catalog answer is what both shapes look like from the browser's side. /// [Fact] public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable); (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever"); (await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty(); } /// A column NodeId is terminal — expanding it is a no-op, never an error. [Fact] public async Task ExpandColumn_yieldsEmpty() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); } /// Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser. [Fact] public async Task AttributesAsync_onNonLeafNode_yieldsEmpty() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); (await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty(); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); (await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty(); } /// /// A garbage NodeId must surface as an carrying an operator-readable /// message — the same contract the Galaxy and OPC UA client sessions have, and what /// DriverBrowseTree.razor renders inline as the node's error. It must not be a /// or an from a blind split. /// [Theory] [InlineData("")] [InlineData("not-a-node-id")] [InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format [InlineData("column:a|b|c|d")] public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId) { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var expand = await Should.ThrowAsync( () => session.ExpandAsync(nodeId, CancellationToken.None)); expand.Message.ShouldNotBeNullOrWhiteSpace(); await Should.ThrowAsync( () => session.AttributesAsync(nodeId, CancellationToken.None)); } /// /// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate. /// Asserted by measuring overlap directly through : a results-only /// assertion would pass with the gate deleted. /// [Fact] public async Task ConcurrentCalls_areSerializedByTheGate() { await using var db = await SqliteBrowseFixture.CreateAsync(); var probe = new ConcurrencyProbe(); var probing = new ConcurrencyProbingDbConnection(db.Connection, probe); await using var session = new SqlBrowseSession(probing, new SqliteDialect()); var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); var calls = new[] { session.RootAsync(CancellationToken.None), session.ExpandAsync(SchemaNodeId, CancellationToken.None), session.ExpandAsync(tableNodeId, CancellationToken.None), session.ExpandAsync(tableNodeId, CancellationToken.None), }; var results = await Task.WhenAll(calls); probe.Peak.ShouldBe(1); // TagValues has three columns — proves the serialized calls still returned real catalog rows. results[2].Count.ShouldBe(3); } /// /// Ownership: the session takes over the connection it is handed and closes it on dispose. The /// AdminUI's BrowseSessionRegistry is the only thing holding a session, so if the session did /// not close the connection, nothing would — every reaped picker would leak one. /// [Fact] public async Task DisposeAsync_closesTheConnectionItWasGiven() { await using var db = await SqliteBrowseFixture.CreateAsync(); var connection = db.OpenNewConnection(); var session = new SqlBrowseSession(connection, new SqliteDialect()); connection.State.ShouldBe(ConnectionState.Open); await session.DisposeAsync(); connection.State.ShouldBe(ConnectionState.Closed); } [Fact] public async Task DisposeAsync_isIdempotent() { await using var db = await SqliteBrowseFixture.CreateAsync(); var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); await session.DisposeAsync(); await Should.NotThrowAsync(async () => await session.DisposeAsync()); } [Fact] public async Task CallsAfterDispose_throwObjectDisposedException() { await using var db = await SqliteBrowseFixture.CreateAsync(); var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); await session.DisposeAsync(); await Should.ThrowAsync( () => session.RootAsync(CancellationToken.None)); await Should.ThrowAsync( () => session.ExpandAsync(SchemaNodeId, CancellationToken.None)); } /// /// A dispose racing an in-flight call must not strand the caller queued behind it. The registry's /// TTL reaper disposes on its own thread while a picker may have two clicks outstanding, so this is the /// ordinary shape, not a corner: the second call is parked in SemaphoreSlim.WaitAsync. /// Disposing the gate does not complete its pending async waiters, and the in-flight call's /// Release() then throws from inside the semaphore — so /// the waiter is never released and the call never returns at all. The session must instead let it /// through to the guarded ObjectDisposedException.ThrowIf(_disposed, this) every other member /// raises. /// [Fact] public async Task DisposeAsync_doesNotStrandACallerQueuedOnTheGate() { await using var db = await SqliteBrowseFixture.CreateAsync(); var probe = new ConcurrencyProbe { Delay = TimeSpan.FromMilliseconds(400) }; var probing = new ConcurrencyProbingDbConnection(db.OpenNewConnection(), probe); var session = new SqlBrowseSession(probing, new SqliteDialect()); // First call takes the gate and holds it for the probe's delay; second parks in WaitAsync. var inFlight = session.RootAsync(CancellationToken.None); await Task.Delay(100, TestContext.Current.CancellationToken); var queued = session.ExpandAsync(SchemaNodeId, CancellationToken.None); await session.DisposeAsync(); // The in-flight call's own outcome is not the assertion — it may fail on the closed connection. try { await inFlight; } catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException) { } // A stranded caller surfaces as TimeoutException here, which reads as "expected // ObjectDisposedException, got TimeoutException" — the honest description of the defect. var disposed = await Should.ThrowAsync( () => queued.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); disposed.ObjectName.ShouldBe( typeof(SqlBrowseSession).FullName, "the failure must be the session's own guarded throw, not the semaphore's"); } /// /// must advance when a call starts, not only when it /// finishes. The AdminUI allows a catalog call 20 s; stamping only on completion leaves a slow expand /// invisible to the registry's TTL reaper for its whole duration, so a session that idled up to the /// edge of the TTL and was then given one long expand can be disposed mid-call — tearing the /// connection down under an open reader. /// [Fact] public async Task LastUsedUtc_advancesWhenACallSTARTS_notOnlyWhenItCompletes() { await using var db = await SqliteBrowseFixture.CreateAsync(); var probe = new ConcurrencyProbe { Delay = TimeSpan.FromSeconds(2) }; var probing = new ConcurrencyProbingDbConnection(db.OpenNewConnection(), probe); await using var session = new SqlBrowseSession(probing, new SqliteDialect()); var atConstruction = session.LastUsedUtc; // DateTime.UtcNow ticks at ~15.6 ms on Windows; without this the entry stamp could equal the // construction stamp and the assertion would be about clock resolution rather than the stamp. await Task.Delay(50, TestContext.Current.CancellationToken); var call = session.RootAsync(CancellationToken.None); // Observed WHILE the call is still running: the completion stamp cannot account for it. await Task.Delay(300, TestContext.Current.CancellationToken); call.IsCompleted.ShouldBeFalse("the probe's delay must still be holding the call open"); session.LastUsedUtc.ShouldBeGreaterThan( atConstruction, "the stamp must advance on entry, not only on completion"); await call; } /// /// The AdminUI bounds every call with a 20 s linked CTS /// (BrowserSessionService.PerCallTimeout) — the session invents no timeout of its own, it just /// has to honour the token it is handed, all the way into the ADO.NET call. /// [Fact] public async Task CancelledToken_isHonoured() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync(() => session.RootAsync(cts.Token)); await Should.ThrowAsync(() => session.ExpandAsync(SchemaNodeId, cts.Token)); } /// /// feeds the registry's idle reaper — a session that never /// refreshed it would be evicted out from under an actively browsing operator. /// [Fact] public async Task LastUsedUtc_advancesOnEverySuccessfulCall() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var atStart = session.LastUsedUtc; await Task.Delay(15, TestContext.Current.CancellationToken); await session.RootAsync(CancellationToken.None); var afterRoot = session.LastUsedUtc; afterRoot.ShouldBeGreaterThan(atStart); await Task.Delay(15, TestContext.Current.CancellationToken); await session.ExpandAsync(SchemaNodeId, CancellationToken.None); var afterExpand = session.LastUsedUtc; afterExpand.ShouldBeGreaterThan(afterRoot); await Task.Delay(15, TestContext.Current.CancellationToken); var columnNodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); await session.AttributesAsync(columnNodeId, CancellationToken.None); session.LastUsedUtc.ShouldBeGreaterThan(afterExpand); } /// /// The browse path binds the dialect's parameter names, not a hardcoded "@schema". /// Driven through (markers :schema/:table, bare bind /// names) — ODP.NET's shape, and the only place the two spellings differ. /// Discriminating in two independent ways: a session still binding "@schema" leaves the /// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the /// recorded names would carry the prefix. /// [Fact] public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames() { await using var db = await SqliteBrowseFixture.CreateAsync(); var dialect = new ColonMarkerDialect(); await using var session = new SqlBrowseSession(db.Connection, dialect); var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn); dialect.BoundParameterNames.ShouldContain("schema"); dialect.BoundParameterNames.ShouldContain("table"); dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':')); // Every catalog command went through the provider hook (Oracle's BindByName lives there). dialect.ConfiguredCommands.ShouldBe(2); } // ---- the wide-row ROW SELECTOR candidate (Gitea #552) ----------------------------------------- /// /// The session states the three parts of a Sql binding it can know — the table, the column, and the /// table's own date/time column, which the commit turns into rowSelector.topByTimestamp. /// Without the third the committed blob is a wide-row tag that never says which ROW it reads, which /// the driver's parser and the authoring gate both refuse. /// [Fact] public async Task AttributesAsync_statesTheTablesSoleDateTimeColumn_asTheRowSelectorCandidate() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.TimestampedTable, SqliteBrowseFixture.TimestampedTableValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.Schema].ShouldBe(SqliteDialect.MainSchema); fields[SqlBrowseAddressFields.Table].ShouldBe(SqliteBrowseFixture.TimestampedTable); fields[SqlBrowseAddressFields.ColumnName].ShouldBe(SqliteBrowseFixture.TimestampedTableValueColumn); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn); } /// /// The tie-break is FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position), /// so two operators browsing the same table commit the same binding. /// The fixture's two candidates are named so catalog order and alphabetical order DISAGREE — /// zulu_time is declared first and sorts last. A tie-break that quietly became "any candidate" /// or "sorted by name" fails here rather than passing by luck. /// [Fact] public async Task AttributesAsync_breaksAMultiCandidateTie_onCatalogOrderNotName() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.MultiTimeTable, SqliteBrowseFixture.MultiTimeValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe( SqliteBrowseFixture.MultiTimeFirstColumn, "the first date/time column in catalog order wins, not the first alphabetically"); // Guards the fixture itself: the assertion above is only a tie-break test while the two disagree. string.CompareOrdinal( SqliteBrowseFixture.MultiTimeFirstColumn, SqliteBrowseFixture.MultiTimeSecondColumn) .ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one"); } /// /// A date-only column is a candidate of last resort, not the first pick. date maps to /// on every dialect, so a candidacy rule that only asked the /// mapped type would commit ORDER BY <date> DESC LIMIT 1 — which ties for every row /// written on the same day, making "the newest row" storage order among the ties. The reader cannot /// warn about it either: the server has already discarded the other rows. /// The fixture's table declares the date-only column FIRST, so this fails against the plain /// first-in-catalog-order rule rather than passing by luck. /// [Fact] public async Task AttributesAsync_prefersATimeBearingColumn_overAnEarlierDateOnlyOne() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.DateOnlyFirstTable, SqliteBrowseFixture.DateOnlyFirstValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe( SqliteBrowseFixture.DateOnlyFirstTimeColumn, "a time-bearing column outranks a date-only one even when the date-only one is declared first"); } /// /// The fallback half of the ranking: when date-only is ALL the table has, it is still stated. Dropping /// date-only types from candidacy outright would instead make such a table unbindable by browse, which /// is a bigger behaviour change than the defect being fixed. /// [Fact] public async Task AttributesAsync_stillStatesADateOnlyColumn_whenTheTableHasNothingBetter() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.DateOnlySoleTable, SqliteBrowseFixture.DateOnlySoleValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.DateOnlySoleColumn); } /// /// The falsification control for the ranking: Oracle's DATE carries a time-of-day /// component, so it is not date-only there and must not be demoted. Same catalog, same columns, /// only the dialect's differs — and the pick reverts to the /// ordinary first-in-catalog-order one. /// A ranking implemented provider-blind ("any family spelled date") passes every other /// test in this file and fails only here. /// [Fact] public async Task AttributesAsync_doesNotDemoteOraclesDATE_whichCarriesATimeOfDay() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new OracleFlavouredSqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.DateOnlyFirstTable, SqliteBrowseFixture.DateOnlyFirstValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe( SqliteBrowseFixture.DateOnlyFirstColumn, "Oracle's DATE is time-bearing, so the first-in-catalog-order tie-break applies unchanged"); } /// /// A table with no date/time column states NO candidate — the key is absent, never blank — so the /// commit can tell "this table cannot be bound by browse" from "the session said nothing". /// The control that makes this sharp: TagValues holds sample_ts, a column that /// LOOKS like a timestamp and is declared TEXT. Candidacy is decided by the mapped type, so a /// name heuristic — which would order the wide-row read by a string column, binding the tag to /// whichever row sorts last lexically — fails this test. /// [Fact] public async Task AttributesAsync_statesNoCandidate_whenNoColumnIsDateTimeTyped() { await using var db = await SqliteBrowseFixture.CreateAsync(); await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields.ShouldContainKey(SqlBrowseAddressFields.Table); fields.ShouldNotContainKey(SqlBrowseAddressFields.TopByTimestamp); // The name-heuristic control, stated explicitly: the tempting column IS present and IS a string. var stringTyped = await session.AttributesAsync( SqlBrowseNodeId.ForColumn(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, "sample_ts"), CancellationToken.None); stringTyped.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); } /// /// The candidate costs no extra catalog round-trip: it is computed from the columns the attributes /// call already read. Counting the dialect's configured commands is the cheapest honest proxy — /// one ListColumnsSql execution, exactly as before #552. /// [Fact] public async Task AttributesAsync_computesTheCandidate_withoutASecondCatalogQuery() { await using var db = await SqliteBrowseFixture.CreateAsync(); var dialect = new ColonMarkerDialect(); await using var session = new SqlBrowseSession(db.Connection, dialect); var nodeId = SqlBrowseNodeId.ForColumn( SqliteDialect.MainSchema, SqliteBrowseFixture.TimestampedTable, SqliteBrowseFixture.TimestampedTableValueColumn); var fields = (await session.AttributesAsync(nodeId, CancellationToken.None)) .ShouldHaveSingleItem().AddressFields.ShouldNotBeNull(); fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn); dialect.ConfiguredCommands.ShouldBe(1); } }