merge: SQL browser/probe review findings resolved (Driver.Sql-009, Driver.Sql.Browser-001/-002/-003)
This commit is contained in:
@@ -63,6 +63,14 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// </summary>
|
||||
private const string ReadOnlySecurityClass = "ViewOnly";
|
||||
|
||||
/// <summary>
|
||||
/// The one SQL type family that maps to <see cref="DriverDataType.DateTime"/> while carrying <b>no
|
||||
/// time of day</b> — SQL Server's and PostgreSQL's <c>date</c>. Ranked below the time-bearing
|
||||
/// families by <see cref="PreferredTimestampColumn"/>; see <see cref="IsDateOnly"/> for the Oracle
|
||||
/// exception.
|
||||
/// </summary>
|
||||
private const string DateOnlyTypeFamily = "date";
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly ISqlDialect _dialect;
|
||||
|
||||
@@ -197,8 +205,18 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
|
||||
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
|
||||
/// Idempotently closes the owned connection. Errors are swallowed: the registry's reaper may be racing
|
||||
/// a server-side disconnect, and a failed close must never surface in the AdminUI.
|
||||
/// <para><b>The gate is deliberately NOT disposed</b> — the same call
|
||||
/// <c>SqlPollReader</c> makes, and for a sharper reason here. This session is disposed by the
|
||||
/// registry's TTL reaper, on its own thread, while a picker may have a second call parked in
|
||||
/// <see cref="SemaphoreSlim.WaitAsync(CancellationToken)"/>. Disposing the semaphore does not complete
|
||||
/// its pending async waiters, and the in-flight call's <c>Release()</c> would then throw
|
||||
/// <see cref="ObjectDisposedException"/> from inside the semaphore — so the parked caller is never
|
||||
/// released and its call never returns at all. Left undisposed, the waiter is admitted and meets the
|
||||
/// guarded <c>ObjectDisposedException.ThrowIf(_disposed, this)</c> every other member raises.
|
||||
/// A <see cref="SemaphoreSlim"/> that was never handed out as a <see cref="WaitHandle"/> holds no
|
||||
/// unmanaged resource, so not disposing it leaks nothing.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -208,9 +226,6 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
|
||||
try { await _connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort: the connection may already be broken or closed. */ }
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
|
||||
@@ -274,12 +289,23 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// row sorts last lexically. So the only input is
|
||||
/// <see cref="ISqlDialect.MapColumnType"/> reporting <see cref="DriverDataType.DateTime"/>, which is
|
||||
/// the same catalog metadata the browse tree already renders per column, on every dialect.</para>
|
||||
/// <para><b>Tie-break: FIRST in catalog order.</b> Every dialect's <see cref="ISqlDialect.ListColumnsSql"/>
|
||||
/// orders by ordinal position (<c>ORDINAL_POSITION</c> / <c>column_id</c>), so "first" means the
|
||||
/// earliest-declared date/time column and is stable across calls, connections and servers — an
|
||||
/// unordered "any candidate" would let two operators browsing the same table commit two differently
|
||||
/// bound tags. On the usual multi-candidate shape (a <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it
|
||||
/// picks the earlier-declared one, which is a preference and not a fact about the data.</para>
|
||||
/// <para><b>Ranked, because "maps to DateTime" includes DATE-ONLY types.</b>
|
||||
/// <see cref="ISqlDialect.MapColumnType"/> reports <see cref="DriverDataType.DateTime"/> for SQL
|
||||
/// Server's and PostgreSQL's <c>date</c> as well — and a wide-row read ordered by a date-only column
|
||||
/// is <c>ORDER BY <date> DESC</c> limited to one row, which <em>ties for every row written on
|
||||
/// the same day</em>: "the newest row" silently becomes storage order among the ties, and the reader
|
||||
/// cannot warn about it because the server has already discarded the others. So a time-bearing
|
||||
/// candidate always outranks a date-only one, wherever the two are declared.
|
||||
/// <b>Falling back to a date-only column is deliberate</b> rather than dropping those types from
|
||||
/// candidacy: a table whose only date/time column is a <c>date</c> stays bindable by browse (with a
|
||||
/// default the operator can see and change) instead of becoming un-committable.</para>
|
||||
/// <para><b>Tie-break within a rank: FIRST in catalog order.</b> Every dialect's
|
||||
/// <see cref="ISqlDialect.ListColumnsSql"/> orders by ordinal position (<c>ORDINAL_POSITION</c> /
|
||||
/// <c>column_id</c>), so "first" means the earliest-declared candidate and is stable across calls,
|
||||
/// connections and servers — an unordered "any candidate" would let two operators browsing the same
|
||||
/// table commit two differently bound tags. On the usual multi-candidate shape (a
|
||||
/// <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it picks the earlier-declared one, which is a preference
|
||||
/// and not a fact about the data.</para>
|
||||
/// <para><b>It is a visible, editable DEFAULT, not a hidden binding.</b> The commit writes it into the
|
||||
/// tag's <c>rowSelector.topByTimestamp</c>, where the typed Sql editor shows it and the operator can
|
||||
/// change it — to the other timestamp, or to a <c>whereColumn</c>/<c>whereValue</c> pair entirely.
|
||||
@@ -289,13 +315,42 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// </remarks>
|
||||
private string? PreferredTimestampColumn(IReadOnlyList<(string Name, string DataType)> columns)
|
||||
{
|
||||
// One pass, remembering the best date-only candidate seen so far: the first time-bearing column wins
|
||||
// outright, and the date-only one is only used if the walk ends without finding one.
|
||||
string? dateOnlyFallback = null;
|
||||
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(column.Name)) continue;
|
||||
if (_dialect.MapColumnType(column.DataType) == DriverDataType.DateTime) return column.Name;
|
||||
if (_dialect.MapColumnType(column.DataType) != DriverDataType.DateTime) continue;
|
||||
|
||||
if (!IsDateOnly(column.DataType)) return column.Name;
|
||||
dateOnlyFallback ??= column.Name;
|
||||
}
|
||||
|
||||
return null;
|
||||
return dateOnlyFallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a column's declared type family carries a date but no time of day — the case that makes an
|
||||
/// <c>ORDER BY … DESC</c> single-row read tie for a whole day's rows.
|
||||
/// </summary>
|
||||
/// <param name="sqlDataType">The bare SQL type family name, as the catalog reported it.</param>
|
||||
/// <returns><see langword="true"/> when the family is date-only on this dialect's backend.</returns>
|
||||
/// <remarks>
|
||||
/// <b>Provider-qualified, because the spelling is not the meaning.</b> <c>date</c> is date-only on SQL
|
||||
/// Server and PostgreSQL, but <b>Oracle's <c>DATE</c> carries a time-of-day component</b> — which is
|
||||
/// why <c>OracleDialect.MapColumnType</c> calls <see cref="DriverDataType.DateTime"/> the faithful
|
||||
/// mapping there rather than a widening. Demoting it on Oracle would change the pick on the backend
|
||||
/// where <c>DATE</c> is the ordinary timestamp type, for no gain.
|
||||
/// <para>The check is a browse-time <em>preference</em> and belongs here rather than on
|
||||
/// <see cref="ISqlDialect"/>: nothing on the poll path cares, and the type map is deliberately about
|
||||
/// what a value IS, not about what it is a good idea to sort by.</para>
|
||||
/// </remarks>
|
||||
private bool IsDateOnly(string sqlDataType)
|
||||
{
|
||||
if (_dialect.Provider == SqlProvider.Oracle) return false;
|
||||
return string.Equals(sqlDataType.Trim(), DateOnlyTypeFamily, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -319,8 +374,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
|
||||
/// <summary>
|
||||
/// Runs one catalog query under the gate and projects every row. The disposed check happens
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
|
||||
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
|
||||
/// <para><see cref="LastUsedUtc"/> is stamped <b>on entry as well as on completion</b>. The AdminUI
|
||||
/// allows a catalog call 20 s (<c>BrowserSessionService.PerCallTimeout</c>) and a queued one waits
|
||||
/// behind it; stamping only on completion leaves a long expand invisible to the registry's TTL reaper
|
||||
/// for its whole duration, so a session that idled to the edge of the TTL and was then given one slow
|
||||
/// call gets reaped mid-call — tearing the connection down under an open reader.</para>
|
||||
/// </summary>
|
||||
private async Task<List<T>> QueryAsync<T>(
|
||||
string sql,
|
||||
@@ -328,6 +387,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
Func<DbDataReader, T> project,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -352,10 +412,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
|
||||
// ignore and must not mask the real result.
|
||||
try { _gate.Release(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
// Unconditional, and it cannot throw: DisposeAsync deliberately leaves the gate alive precisely
|
||||
// so this release still reaches whoever is parked behind us.
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
|
||||
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
|
||||
/// the override is visible rather than silent.</para>
|
||||
/// <para><b>Trust boundary: this is an outbound-connect capability, gated by the AdminUI page policy.</b>
|
||||
/// Accepting a literal means dialling an arbitrary host and port — plus whatever the provider's keyword
|
||||
/// set allows — from the AdminUI host, before anything is persisted. The control is that the only
|
||||
/// surfaces reaching a browse session are <c>/raw</c> and <c>/uns</c>, both
|
||||
/// <c>[Authorize(Policy = AdminUiPolicies.ConfigEditor)]</c> (Administrator or Designer), with no HTTP
|
||||
/// endpoint for browse at all; the hygiene below is supporting, not the control. Recorded in
|
||||
/// <c>docs/security.md</c> §"Address-picker browse is an outbound-connect capability".</para>
|
||||
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
|
||||
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
|
||||
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
|
||||
|
||||
@@ -19,7 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
|
||||
/// catch.</para>
|
||||
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
|
||||
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
|
||||
/// failure class, never the string — the same credential discipline the factory and the driver keep.
|
||||
/// That includes the JSON parser: a malformed config is reported as a class, with the parser's own
|
||||
/// message withheld exactly as <c>SqlDriverBrowser.ParseDocument</c> withholds it, because the malformed
|
||||
/// text may itself be a pasted connection string.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverProbe : IDriverProbe
|
||||
{
|
||||
@@ -80,7 +83,16 @@ public sealed class SqlDriverProbe : IDriverProbe
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
|
||||
{
|
||||
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
|
||||
// The parser's message is WITHHELD, matching SqlDriverBrowser.ParseDocument. Nothing has been
|
||||
// extracted at this point, so there is no known secret to redact the message against — and the
|
||||
// blob both types read is the one the AdminUI Sql picker composes, which carries a top-level
|
||||
// `connectionString` literal. A malformed paste is therefore exactly the text that reaches here,
|
||||
// and System.Text.Json quotes the offending token and states the JSON path and byte position.
|
||||
return new DriverProbeResult(
|
||||
false,
|
||||
"Config JSON is not valid JSON. (The parser's message is withheld because the malformed "
|
||||
+ "text may carry a connection string.)",
|
||||
null);
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
|
||||
Reference in New Issue
Block a user