cd4b29a368
(1) DisposeAsync disposed _gate unconditionally. SemaphoreSlim.Dispose does not complete pending async waiters, and the in-flight call's Release() then threw ObjectDisposedException from inside the semaphore (swallowed by the finally), so a caller parked in WaitAsync was never released and its call never returned. The gate is now left alive - the pattern SqlPollReader documents - so the waiter is admitted and meets the session's own guarded ThrowIf(_disposed, this). (2) LastUsedUtc advanced only on completion, so a slow catalog call (the AdminUI allows 20 s) was invisible to the TTL reaper for its whole duration and a session at the edge of its TTL could be disposed mid-call. It is now stamped on entry too.
661 lines
34 KiB
C#
661 lines
34 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Walks <see cref="SqlBrowseSession"/> over a real, seeded SQLite catalog through the
|
|
/// <see cref="SqliteDialect"/> — the only non-SQL-Server dialect in the tree, and therefore the control
|
|
/// that proves the session runs the <em>dialect's</em> catalog SQL rather than <c>INFORMATION_SCHEMA</c>
|
|
/// with a quoting helper bolted on.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The plan's headline case: table expand yields columns as committable leaves.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An exotic declared type must fall back to <see cref="DriverDataType.String"/> via
|
|
/// <c>MapColumnType</c> rather than crash the browse — a table with one <c>geography</c> column must
|
|
/// still render.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The end-to-end awkward-identifier walk: a table named <c>a.b|c</c> holding a column named
|
|
/// <c>x|y</c>. Both characters are the ones a naive <c>schema.table|column</c> NodeId splits on, so
|
|
/// this is the case that proves the encoding, not just the codec unit test.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A table whose <em>name</em> is an injection payload. If any catalog query concatenated the name
|
|
/// instead of binding <c>@table</c>, expanding it would drop <c>TagValues</c>.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>A hostile <em>schema</em> name takes the same bound path at the table level.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>A column NodeId is terminal — expanding it is a no-op, never an error.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A garbage NodeId must surface as an <see cref="ArgumentException"/> carrying an operator-readable
|
|
/// message — the same contract the Galaxy and OPC UA client sessions have, and what
|
|
/// <c>DriverBrowseTree.razor</c> renders inline as the node's error. It must not be a
|
|
/// <see cref="NullReferenceException"/> or an <see cref="IndexOutOfRangeException"/> from a blind split.
|
|
/// </summary>
|
|
[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<ArgumentException>(
|
|
() => session.ExpandAsync(nodeId, CancellationToken.None));
|
|
expand.Message.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
await Should.ThrowAsync<ArgumentException>(
|
|
() => session.AttributesAsync(nodeId, CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>
|
|
/// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate.
|
|
/// Asserted by measuring overlap directly through <see cref="ConcurrencyProbe"/>: a results-only
|
|
/// assertion would pass with the gate deleted.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ownership: the session takes over the connection it is handed and closes it on dispose. The
|
|
/// AdminUI's <c>BrowseSessionRegistry</c> is the only thing holding a session, so if the session did
|
|
/// not close the connection, nothing would — every reaped picker would leak one.
|
|
/// </summary>
|
|
[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<ObjectDisposedException>(
|
|
() => session.RootAsync(CancellationToken.None));
|
|
await Should.ThrowAsync<ObjectDisposedException>(
|
|
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A dispose racing an in-flight call must not <b>strand</b> 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 <c>SemaphoreSlim.WaitAsync</c>.
|
|
/// <para>Disposing the gate does not complete its pending async waiters, and the in-flight call's
|
|
/// <c>Release()</c> then throws <see cref="ObjectDisposedException"/> 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 <c>ObjectDisposedException.ThrowIf(_disposed, this)</c> every other member
|
|
/// raises.</para>
|
|
/// </summary>
|
|
[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<ObjectDisposedException>(
|
|
() => 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="IBrowseSession.LastUsedUtc"/> must advance when a call <b>starts</b>, 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.
|
|
/// </summary>
|
|
[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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The AdminUI bounds every call with a 20 s linked CTS
|
|
/// (<c>BrowserSessionService.PerCallTimeout</c>) — 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.
|
|
/// </summary>
|
|
[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<OperationCanceledException>(() => session.RootAsync(cts.Token));
|
|
await Should.ThrowAsync<OperationCanceledException>(() => session.ExpandAsync(SchemaNodeId, cts.Token));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="IBrowseSession.LastUsedUtc"/> feeds the registry's idle reaper — a session that never
|
|
/// refreshed it would be evicted out from under an actively browsing operator.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The browse path binds the <em>dialect's</em> parameter names, not a hardcoded <c>"@schema"</c>.
|
|
/// Driven through <see cref="ColonMarkerDialect"/> (markers <c>:schema</c>/<c>:table</c>, bare bind
|
|
/// names) — ODP.NET's shape, and the only place the two spellings differ.
|
|
/// <para>Discriminating in two independent ways: a session still binding <c>"@schema"</c> leaves the
|
|
/// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the
|
|
/// recorded names would carry the prefix.</para>
|
|
/// </summary>
|
|
[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) -----------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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 <c>rowSelector.topByTimestamp</c>.
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <para>The fixture's two candidates are named so catalog order and alphabetical order DISAGREE —
|
|
/// <c>zulu_time</c> 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.</para>
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A <b>date-only</b> column is a candidate of last resort, not the first pick. <c>date</c> maps to
|
|
/// <see cref="DriverDataType.DateTime"/> on every dialect, so a candidacy rule that only asked the
|
|
/// mapped type would commit <c>ORDER BY <date> DESC LIMIT 1</c> — 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.
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The falsification control for the ranking: <b>Oracle's <c>DATE</c> carries a time-of-day
|
|
/// component</b>, so it is not date-only there and must not be demoted. Same catalog, same columns,
|
|
/// only the dialect's <see cref="ISqlDialect.Provider"/> differs — and the pick reverts to the
|
|
/// ordinary first-in-catalog-order one.
|
|
/// <para>A ranking implemented provider-blind ("any family spelled <c>date</c>") passes every other
|
|
/// test in this file and fails only here.</para>
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// <para>The control that makes this sharp: <c>TagValues</c> holds <c>sample_ts</c>, a column that
|
|
/// LOOKS like a timestamp and is declared <c>TEXT</c>. 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.</para>
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>ListColumnsSql</c> execution, exactly as before #552.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|