fix(sql): row-cap key-value and where-pair plans with a TOP/LIMIT N+1 dialect seam (R06:S-1)
Key-value and where-pair plans had no row limit, so a selector pointed at a history or events table materialised every matching row on every poll. ISqlDialect gains RowLimitPrefix/RowLimitSuffix(n) (SQL Server TOP (n), Postgres LIMIT n, Oracle FETCH FIRST n ROWS ONLY); plans carry a RowCap (distinct keys, or 1) and ask for RowCap+1 rows; the reader also stops client-side at RowCap+1. Getting the extra row proves the selector ambiguous: the group publishes BadConfigurationError with a rate-limited warning. The reader reports those answered refusals so the driver counts them as answer evidence, and any refusal-only poll now restores Healthy after a comms fault. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -166,6 +166,17 @@ emits a `SqlQueryPlan` per distinct group key. Grouping is deterministic (groups
|
||||
order of their first member, members keep their input order, parameters keep first-appearance order),
|
||||
so an unchanged configuration produces byte-identical SQL.
|
||||
|
||||
**Every key-value and where-pair query is row-capped (R06:S-1).** A key-value plan asks for one row more
|
||||
than it has distinct keys (`TOP (n+1)` / `LIMIT n+1` / `FETCH FIRST n+1 ROWS ONLY`), a where-pair wide-row plan
|
||||
for two, and the reader stops reading there even if a provider ignores the limit. Receiving the extra row
|
||||
proves the selector is ambiguous — a history/events table, or a where column that is not unique — so every
|
||||
member of that group publishes `BadConfigurationError` with a rate-limited warning naming the table and
|
||||
selector, instead of the old behaviour of materialising every matching row on every poll and publishing an
|
||||
arbitrary "last row" at Good. A duplicate that fits inside the cap (a duplicated key alongside an absent one)
|
||||
still resolves last-row-wins. `topByTimestamp` keeps its single-row limit. An over-cap answer, like every
|
||||
other refusal (absent row, NULL cell, uncoercible cell), is the database answering: it advances
|
||||
`LastSuccessfulRead` and restores `Healthy` after a comms fault.
|
||||
|
||||
⚠️ **Group keys are case-sensitive on identifiers.** Two tags authored `dbo.T` and `DBO.T` plan as
|
||||
**two** groups — one extra round-trip, not a correctness problem, but worth knowing if a group count
|
||||
looks higher than expected.
|
||||
|
||||
@@ -177,6 +177,31 @@ public interface ISqlDialect
|
||||
/// </summary>
|
||||
string SingleRowLimitSuffix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SingleRowLimitPrefix"/> position's fragment for an <paramref name="rows"/>-row limit —
|
||||
/// T-SQL's <c>"TOP (n) "</c>; empty for a dialect that limits at the end. Same trailing-space rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Added for the key-value / where-pair row cap (R06:S-1): those plans used to carry no limit, so an
|
||||
/// ambiguous selector against a history table materialised every matching row on every poll. The
|
||||
/// planner asks for <c>N + 1</c> rows — one more than the plan can use — so ambiguity stays observable
|
||||
/// (the reader sees the extra row and publishes a configuration error) without unbounded reads.
|
||||
/// </remarks>
|
||||
/// <param name="rows">The row limit; at least 1.</param>
|
||||
/// <returns>The fragment, possibly empty.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
|
||||
string RowLimitPrefix(int rows);
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SingleRowLimitSuffix"/> position's fragment for an <paramref name="rows"/>-row limit —
|
||||
/// <c>" LIMIT n"</c> / <c>" FETCH FIRST n ROWS ONLY"</c>; empty for a dialect that limits after
|
||||
/// <c>SELECT</c>. Same leading-space rule. See <see cref="RowLimitPrefix"/>.
|
||||
/// </summary>
|
||||
/// <param name="rows">The row limit; at least 1.</param>
|
||||
/// <returns>The fragment, possibly empty.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
|
||||
string RowLimitSuffix(int rows);
|
||||
|
||||
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||
string ListSchemasSql { get; }
|
||||
|
||||
|
||||
@@ -124,6 +124,21 @@ public sealed class OracleDialect : ISqlDialect
|
||||
// statement shape, which this seam deliberately leaves with the planner.
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// The same 12c+ row-limiting clause as SingleRowLimitSuffix; valid after a WHERE with no ORDER BY.
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " FETCH FIRST " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ROWS ONLY";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
|
||||
// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
|
||||
|
||||
@@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// The schemas holding relations this connection can actually see, system schemas excluded.
|
||||
//
|
||||
// Sourced from information_schema.tables, not information_schema.schemata - a deliberate
|
||||
|
||||
@@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
// Parenthesised: TOP (n) is the documented modern form, and TOP n without parentheses is the
|
||||
// backwards-compatibility spelling.
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return "TOP (" + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + ") ";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||
|
||||
@@ -519,9 +519,9 @@ public sealed class SqlDriver
|
||||
|
||||
try
|
||||
{
|
||||
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(snapshots);
|
||||
return snapshots;
|
||||
var result = await _reader.ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(result.Snapshots, result.RowCapExceededGroups);
|
||||
return result.Snapshots;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -608,7 +608,13 @@ public sealed class SqlDriver
|
||||
/// cadence rather than backed off from.</para>
|
||||
/// </summary>
|
||||
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
|
||||
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
/// <param name="answeredRefusals">
|
||||
/// Groups the database answered but the driver refused (an over-cap result set, R06:S-1). Their
|
||||
/// members carry <see cref="SqlStatusCodes.BadConfigurationError"/>, which alone is not answer evidence
|
||||
/// (the planner mints it before any I/O), so the count is what makes them count as the database
|
||||
/// answering.
|
||||
/// </param>
|
||||
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots, int answeredRefusals = 0)
|
||||
{
|
||||
if (snapshots.Count == 0) return;
|
||||
|
||||
@@ -625,6 +631,8 @@ public sealed class SqlDriver
|
||||
if (IsAnswerEvidence(snapshot.StatusCode)) answered++;
|
||||
}
|
||||
|
||||
answered += answeredRefusals;
|
||||
|
||||
if (unreachable > 0)
|
||||
{
|
||||
Degrade(
|
||||
@@ -660,17 +668,12 @@ public sealed class SqlDriver
|
||||
|
||||
if (answered == 0) return; // authoring-class Bad only — not a statement about the database.
|
||||
|
||||
if (good == 0)
|
||||
{
|
||||
// The database answered, just not with a value: an absent row, a NULL cell, a cell that would
|
||||
// not coerce. Those are data/authoring facts, so the health STATE is deliberately left exactly
|
||||
// as it was — but they are still proof the transport works, and the evidence clock the
|
||||
// comms-loss watchdog reads must say so.
|
||||
TouchLastSuccessfulRead();
|
||||
TransitionHostTo(HostState.Running);
|
||||
return;
|
||||
}
|
||||
|
||||
// Good values, OR a database that answered with refusals only — an absent row, a NULL cell, an
|
||||
// uncoercible cell, an over-cap result set. A refusal is the database answering: it advances the
|
||||
// evidence clock AND restores Healthy after a comms fault, exactly as a Good read does. Leaving the
|
||||
// state as it was latched a recovered database Degraded behind every poll that read only refusals.
|
||||
//
|
||||
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
|
||||
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
|
||||
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
|
||||
|
||||
@@ -230,15 +230,21 @@ public static class SqlGroupPlanner
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
// Row cap (R06:S-1): one row per distinct key is all this plan can use, so it asks for ONE more — enough
|
||||
// to see a source breaking the one-row-per-key contract, never enough to pull a history table's every
|
||||
// matching row into memory on every poll.
|
||||
var rowCap = values.Count;
|
||||
var sql = new StringBuilder("SELECT ")
|
||||
.Append(dialect.RowLimitPrefix(rowCap + 1))
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||
.Append(dialect.RowLimitSuffix(rowCap + 1))
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected, rowCap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -270,14 +276,18 @@ public static class SqlGroupPlanner
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
// Capped at TWO rows (R06:S-1): the selector must identify one row, and the second is the proof it
|
||||
// does not. A TOP 1 alone would hide that ambiguity (see SqlPollReader.SelectWideRow); no cap at all
|
||||
// materialised every matching row of an events table on every poll.
|
||||
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||
"SELECT ", dialect.RowLimitPrefix(2), columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker,
|
||||
dialect.RowLimitSuffix(2));
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||
[first.RowSelectorValue], members, selected);
|
||||
[first.RowSelectorValue], members, selected, RowCap: 1);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
|
||||
@@ -162,11 +162,24 @@ public sealed class SqlPollReader : IDisposable
|
||||
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
=> (await ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false)).Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReadAsync"/> plus what the snapshots alone cannot say: how many groups the database
|
||||
/// ANSWERED with more rows than the plan can use (R06:S-1). Those groups publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/> — the same code an un-plannable definition gets
|
||||
/// before any I/O — so the driver needs this count to treat them as the answer evidence they are.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The RawPaths to read.</param>
|
||||
/// <param name="cancellationToken">The caller's token; see <see cref="ReadAsync"/>.</param>
|
||||
/// <returns>The snapshots and the answered-refusal count.</returns>
|
||||
public async Task<SqlPollResult> ReadDetailedAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
if (results.Length == 0) return results;
|
||||
if (results.Length == 0) return new SqlPollResult(results, 0);
|
||||
|
||||
var readAt = DateTime.UtcNow;
|
||||
|
||||
@@ -189,19 +202,23 @@ public sealed class SqlPollReader : IDisposable
|
||||
positions.Add(i);
|
||||
}
|
||||
|
||||
if (definitions.Count > 0)
|
||||
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
|
||||
var rowCapExceeded = definitions.Count > 0
|
||||
? await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false)
|
||||
: 0;
|
||||
|
||||
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
|
||||
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
|
||||
|
||||
return results;
|
||||
return new SqlPollResult(results, rowCapExceeded);
|
||||
}
|
||||
|
||||
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
|
||||
private async Task ReadGroupsAsync(
|
||||
/// <summary>
|
||||
/// Plans the resolved definitions, runs every group concurrently, and slices the results back.
|
||||
/// Returns how many groups came back over their row cap.
|
||||
/// </summary>
|
||||
private async Task<int> ReadGroupsAsync(
|
||||
List<SqlTagDefinition> definitions,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
DataValueSnapshot[] results,
|
||||
@@ -227,7 +244,7 @@ public sealed class SqlPollReader : IDisposable
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
|
||||
@@ -241,8 +258,14 @@ public sealed class SqlPollReader : IDisposable
|
||||
// leaves a sibling group running against a connection nobody is watching.
|
||||
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
|
||||
|
||||
var rowCapExceeded = 0;
|
||||
for (var g = 0; g < plans.Count; g++)
|
||||
{
|
||||
Slice(plans[g], outcomes[g], slots, results, readAt);
|
||||
if (ReferenceEquals(outcomes[g], GroupResult.RowCapExceeded)) rowCapExceeded++;
|
||||
}
|
||||
|
||||
return rowCapExceeded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -502,8 +525,11 @@ public sealed class SqlPollReader : IDisposable
|
||||
columnIndex[selected[c]] = c;
|
||||
}
|
||||
|
||||
// The client-side half of the row cap (R06:S-1): the statement already asks for RowCap + 1 rows, but a
|
||||
// provider that cannot spell a limit must not turn that into an unbounded read either.
|
||||
var limit = plan.RowCap is { } cap ? cap + 1 : int.MaxValue;
|
||||
var rows = new List<object?[]>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
while (rows.Count < limit && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = new object?[selected.Count];
|
||||
for (var c = 0; c < selected.Count; c++)
|
||||
@@ -511,6 +537,25 @@ public sealed class SqlPollReader : IDisposable
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
if (plan.RowCap is { } rowCap && rows.Count > rowCap)
|
||||
{
|
||||
if (ShouldWarnAboutContract())
|
||||
{
|
||||
var first = plan.Members[0];
|
||||
var selector = plan.Model == SqlTagModel.KeyValue
|
||||
? string.Concat(first.KeyColumn, " IN (", rowCap.ToString(CultureInfo.InvariantCulture), " key(s))")
|
||||
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned more rows than the {Model} selector '{Selector}' can use " +
|
||||
"(at most {Cap}) in one poll, so the selector is ambiguous — a history/events table, or a " +
|
||||
"where column that is not unique. Its {Members} tag(s) publish BadConfigurationError and " +
|
||||
"nothing past row {Limit} is read. Point the tag at a current-values table or a latest-row view.",
|
||||
first.Table, plan.Model, selector, rowCap, plan.Members.Count, rowCap + 1);
|
||||
}
|
||||
|
||||
return GroupResult.RowCapExceeded;
|
||||
}
|
||||
|
||||
return GroupResult.Ok(rows, columnIndex, typeNames);
|
||||
}
|
||||
|
||||
@@ -609,15 +654,14 @@ public sealed class SqlPollReader : IDisposable
|
||||
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
|
||||
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
|
||||
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
|
||||
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
|
||||
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
|
||||
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
|
||||
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
|
||||
/// (<c>Rows.Count > 1</c>), converting a loud misconfiguration into a permanently silent one —
|
||||
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
|
||||
/// form keeps its row limit because there the <c>ORDER BY</c> at least fixes <em>which</em> rows can
|
||||
/// be first. The accepted cost is that an ambiguous selector materialises every matching row for one
|
||||
/// poll; the warning is what gets it fixed.</para>
|
||||
/// <para><b>Why the where-pair form is capped at TWO rows, not one (R06:S-1).</b> A <c>TOP 1</c> with no
|
||||
/// <c>ORDER BY</c> is not deterministic — it returns whichever row the plan produces first — and it would
|
||||
/// destroy the only evidence that the selector is ambiguous, converting a loud misconfiguration into a
|
||||
/// permanently silent one. No limit at all materialised every matching row of an events table on every
|
||||
/// poll. Two rows keeps the evidence and bounds the read: a second row makes the group publish
|
||||
/// <c>BadConfigurationError</c> in <c>MaterialiseAsync</c>, so a where-pair group never reaches this
|
||||
/// method with more than one row. The multi-row warning below is now reached only by a plan with no
|
||||
/// cap.</para>
|
||||
/// <para><b>⚠ The <c>topByTimestamp</c> answer is only defined when the ordering column is unique</b>
|
||||
/// <c>ORDER BY ts DESC</c> plus a one-row limit over a column with ties returns an
|
||||
/// arbitrary row among the tied ones, and — unlike the where-pair form — this method can never
|
||||
@@ -807,6 +851,13 @@ public sealed class SqlPollReader : IDisposable
|
||||
public static GroupResult Failed { get; } =
|
||||
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>
|
||||
/// The database answered with more rows than the plan can use — an ambiguous selector (R06:S-1).
|
||||
/// A configuration fact, not a database fault; the driver counts it as answer evidence.
|
||||
/// </summary>
|
||||
public static GroupResult RowCapExceeded { get; } =
|
||||
new(SqlStatusCodes.BadConfigurationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
|
||||
public uint FailureStatus { get; }
|
||||
|
||||
@@ -835,6 +886,14 @@ public sealed class SqlPollReader : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One poll's snapshots plus the answer evidence they cannot carry themselves.</summary>
|
||||
/// <param name="Snapshots">One snapshot per requested reference, at the same index.</param>
|
||||
/// <param name="RowCapExceededGroups">
|
||||
/// Groups the database answered with more rows than their plan can use; their members carry
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/>.
|
||||
/// </param>
|
||||
public sealed record SqlPollResult(IReadOnlyList<DataValueSnapshot> Snapshots, int RowCapExceededGroups);
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
|
||||
/// them back with.
|
||||
|
||||
@@ -55,6 +55,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
|
||||
/// reader's map from result-set ordinal to source column.
|
||||
/// </param>
|
||||
/// <param name="RowCap">
|
||||
/// The most rows this plan can USE — its distinct key count (key-value) or 1 (a where-pair wide row);
|
||||
/// <see langword="null"/> when the statement already limits itself (topByTimestamp). <see cref="SqlText"/>
|
||||
/// asks for <c>RowCap + 1</c>, and the reader stops there: receiving the extra row proves the selector is
|
||||
/// ambiguous (a history table, a non-unique where column) and the group publishes
|
||||
/// <c>BadConfigurationError</c> instead of materialising every matching row on every poll (R06:S-1).
|
||||
/// </param>
|
||||
public sealed record SqlQueryPlan(
|
||||
SqlTagModel Model,
|
||||
object GroupKey,
|
||||
@@ -63,7 +70,8 @@ public sealed record SqlQueryPlan(
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
IReadOnlyList<string> SelectedColumns,
|
||||
int? RowCap = null)
|
||||
{
|
||||
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
|
||||
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
|
||||
|
||||
+4
@@ -38,6 +38,10 @@ public sealed class OracleFlavouredSqliteDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => _inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => _inner.RowLimitSuffix(rows);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ public sealed class ColonMarkerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
|
||||
|
||||
|
||||
@@ -210,6 +210,15 @@ public class OracleDialectTests
|
||||
public void LivenessSql_selectsFromDual()
|
||||
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
|
||||
|
||||
[Fact]
|
||||
public void RowLimit_isFetchFirstN_inTheSuffixPosition()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("");
|
||||
d.RowLimitSuffix(3).ShouldBe(" FETCH FIRST 3 ROWS ONLY");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitSuffix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
|
||||
@@ -226,6 +226,15 @@ public class PostgresDialectTests
|
||||
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowLimit_isLimitN_inTheSuffixPosition()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("");
|
||||
d.RowLimitSuffix(3).ShouldBe(" LIMIT 3");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitSuffix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
|
||||
@@ -113,6 +113,10 @@ public sealed class SqlCatalogDefaultSchemaTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql => twoSchemas
|
||||
? Inner.ListSchemasSql + " UNION ALL SELECT 'other' AS TABLE_SCHEMA"
|
||||
: Inner.ListSchemasSql;
|
||||
|
||||
@@ -374,6 +374,10 @@ public sealed class SqlCatalogGateDriverTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql { get; } = listSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
@@ -272,14 +272,15 @@ public sealed class SqlDriverTests
|
||||
// ---- the comms-loss watchdog's evidence clock (Driver.Sql-006) ----
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_withoutChangingHealth()
|
||||
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_andRestoresHealthy()
|
||||
{
|
||||
// Driver.Sql-006. LastSuccessfulRead is what DriverInstanceActor's comms-loss watchdog reads: a
|
||||
// non-Healthy driver whose clock has been frozen for 90 s is force-reconnected (Gitea #527), on the
|
||||
// premise that the clock is the honest evidence the transport works. BadNoData IS that evidence —
|
||||
// the query ran and the row is absent — so a driver whose key set was deleted must not be
|
||||
// reconnected every 90 s against a database answering every poll on time. The health STATE stays
|
||||
// exactly as it was: an absent row is a data fact, not a connectivity one.
|
||||
// reconnected every 90 s against a database answering every poll on time. And since the database
|
||||
// answered, the driver is Healthy again (lane rule: a refusal means the device answered — it used to
|
||||
// leave the Degraded latched behind every refusal-only poll).
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Missing", SqlitePollFixture.AbsentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
@@ -295,7 +296,7 @@ public sealed class SqlDriverTests
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
var health = driver.GetHealth();
|
||||
health.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); // RED pre-fix: frozen
|
||||
health.State.ShouldBe(DriverState.Degraded); // classification untouched
|
||||
health.State.ShouldBe(DriverState.Healthy); // the database answered
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -356,6 +357,34 @@ public sealed class SqlDriverTests
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1.</b> An over-cap result set publishes BadConfigurationError — the code the planner also
|
||||
/// mints before any I/O, which on its own is NOT answer evidence (theory above). The reader's
|
||||
/// answered-refusal count is what makes it count: the database answered, so the clock advances and a
|
||||
/// comms-Degraded driver is Healthy again. Control: the same snapshot with no count leaves both alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObservePollOutcome_aRowCapRefusal_isAnswerEvidence_andRestoresHealthy()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var refused = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, DateTime.UtcNow);
|
||||
|
||||
driver.ObservePollOutcome([new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow)]);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
|
||||
driver.ObservePollOutcome([refused]); // no count: planner-minted, no evidence
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
|
||||
driver.ObservePollOutcome([refused], answeredRefusals: 1); // the database answered, over-cap
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
}
|
||||
|
||||
// ---- Driver.Sql-010: the health field is read-modify-written from several poll loops ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class SqlGroupPlannerChunkingTests
|
||||
plan.ParameterNames.ShouldBe(
|
||||
Enumerable.Range(0, plan.Parameters.Count)
|
||||
.Select(i => "@k" + i.ToString(CultureInfo.InvariantCulture)));
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
plan.SqlText.ShouldContain("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,10 @@ public sealed class SqlGroupPlannerChunkingTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql => Inner.ListSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
@@ -29,6 +29,38 @@ public class SqlGroupPlannerTests
|
||||
RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue,
|
||||
RowSelectorTopByTimestamp: topByTimestamp);
|
||||
|
||||
// ---- R06:S-1: the row cap ----
|
||||
|
||||
/// <summary>
|
||||
/// A key-value plan asks for one row more than it has distinct keys, and a where-pair wide-row plan for
|
||||
/// two: enough to SEE an ambiguous selector, never enough to materialise a history table every poll.
|
||||
/// The topByTimestamp form keeps its single-row limit and needs no cap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Plans_carryARowCap_andAskForOneRowMore()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null),
|
||||
KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null),
|
||||
KvTag("C", "dbo.TagValues", "tag_name", "k1", "num_value", null), // same key: one row serves both
|
||||
WideTag("W", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
|
||||
WideTag("T", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
|
||||
], new SqlServerDialect()).ToList();
|
||||
|
||||
var kv = plans.Single(p => p.Model == SqlTagModel.KeyValue);
|
||||
kv.RowCap.ShouldBe(2);
|
||||
kv.SqlText.ShouldStartWith("SELECT TOP (3) ");
|
||||
|
||||
var wherePair = plans.Single(p => p.Parameters.Count == 1 && p.Model == SqlTagModel.WideRow);
|
||||
wherePair.RowCap.ShouldBe(1);
|
||||
wherePair.SqlText.ShouldStartWith("SELECT TOP (2) ");
|
||||
|
||||
var top = plans.Single(p => p.Parameters.Count == 0);
|
||||
top.RowCap.ShouldBeNull();
|
||||
top.SqlText.ShouldStartWith("SELECT TOP 1 ");
|
||||
}
|
||||
|
||||
// ---- the plan's golden case ----
|
||||
|
||||
[Fact]
|
||||
@@ -58,7 +90,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (2) [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
|
||||
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
|
||||
@@ -75,7 +107,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (2) [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].TimestampColumn.ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -181,7 +213,7 @@ public class SqlGroupPlannerTests
|
||||
|
||||
var plan = plans[0];
|
||||
plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count);
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")"); // T-SQL caps at the front
|
||||
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
||||
}
|
||||
|
||||
@@ -201,7 +233,7 @@ public class SqlGroupPlannerTests
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1) LIMIT 3");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||
}
|
||||
@@ -213,7 +245,7 @@ public class SqlGroupPlannerTests
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("= :w");
|
||||
plans[0].SqlText.ShouldEndWith("= :w LIMIT 2");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
}
|
||||
@@ -277,7 +309,7 @@ public class SqlGroupPlannerTests
|
||||
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
"SELECT TOP (2) [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@w" });
|
||||
plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" });
|
||||
@@ -313,7 +345,7 @@ public class SqlGroupPlannerTests
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" });
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
"SELECT TOP (2) [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -424,7 +456,7 @@ public class SqlGroupPlannerTests
|
||||
// A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
|
||||
// bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (2) [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
}
|
||||
|
||||
// ---- contract edges ----
|
||||
|
||||
@@ -187,7 +187,7 @@ public sealed class SqlPollReaderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
|
||||
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_publishesAConfigurationError_andWarns()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
|
||||
@@ -201,8 +201,11 @@ public sealed class SqlPollReaderTests
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
||||
|
||||
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
// R06:S-1: the where-pair plan now asks for TWO rows (one more than it can use). Getting the second is
|
||||
// proof the selector is ambiguous, so the group publishes a configuration error rather than a
|
||||
// storage-order-dependent "last row" value — and nothing past that second row is ever read.
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[0].Value.ShouldBeNull();
|
||||
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
@@ -277,13 +280,93 @@ public sealed class SqlPollReaderTests
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
// Two keys, one absent: the duplicate fits inside the row cap (2), so it is not provably a history
|
||||
// table — the last-row-wins rule still applies there (the cap test below covers the other side).
|
||||
var reader = Reader(fixture,
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Gone", SqlitePollFixture.AbsentKey));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
||||
var snapshots = await reader.ReadAsync(["Speed", "Gone"], CancellationToken.None);
|
||||
|
||||
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
|
||||
// deterministic — last occurrence in reader order — rather than silently arbitrary.
|
||||
snapshots[0].Value.ShouldBe(99.0);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1.</b> A key-value tag pointed at a history table (many rows per key) used to pull every
|
||||
/// matching row into memory on every poll. The plan now asks for one row more than it has keys; a full
|
||||
/// result set proves the selector is ambiguous, so every member publishes a configuration error and a
|
||||
/// (rate-limited) warning names the table. The positive control: the same tag against a clean source
|
||||
/// reads Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_keyValueRowCapExceeded_publishesAConfigurationError_forEveryMember()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var clean = Reader(fixture, logger, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
(await clean.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
logger.Entries.ShouldBeEmpty();
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
fixture.Execute(string.Create(CultureInfo.InvariantCulture,
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', {i}, '2026-07-24T11:00:00Z')"));
|
||||
}
|
||||
|
||||
var reader = Reader(fixture, logger, KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Speed2", SqlitePollFixture.PresentKey));
|
||||
var snapshots = await reader.ReadAsync(["Speed", "Speed2"], CancellationToken.None);
|
||||
|
||||
snapshots.ShouldAllBe(x => x.StatusCode == SqlStatusCodes.BadConfigurationError);
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
warning.Message.ShouldContain(SqlitePollFixture.KeyValueTable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client-side half of the cap: a dialect whose limit fragments are empty (a provider that cannot
|
||||
/// spell one) must still stop reading at cap + 1 and report the same configuration error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aDialectThatEmitsNoLimit_isStillCappedClientSide()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
AddSecondRowForPresentStation(fixture);
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new NoLimitDialect(),
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
}
|
||||
|
||||
/// <summary>SQLite with the row-limit fragments blanked — the "provider cannot spell a limit" shape.</summary>
|
||||
private sealed class NoLimitDialect : ISqlDialect
|
||||
{
|
||||
private readonly SqliteDialect _inner = new();
|
||||
public SqlProvider Provider => _inner.Provider;
|
||||
public System.Data.Common.DbProviderFactory Factory => _inner.Factory;
|
||||
public int MaxBindParameters => _inner.MaxBindParameters;
|
||||
public string LivenessSql => _inner.LivenessSql;
|
||||
public string SingleRowLimitPrefix => _inner.SingleRowLimitPrefix;
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
public string RowLimitPrefix(int rows) => string.Empty;
|
||||
public string RowLimitSuffix(int rows) => string.Empty;
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
public string DefaultSchemaSql => _inner.DefaultSchemaSql;
|
||||
public string ListTablesSql => _inner.ListTablesSql;
|
||||
public string ListColumnsSql => _inner.ListColumnsSql;
|
||||
public string QuoteIdentifier(string ident) => _inner.QuoteIdentifier(ident);
|
||||
public string ParameterMarker(string baseName) => _inner.ParameterMarker(baseName);
|
||||
public string ParameterBindName(string baseName) => _inner.ParameterBindName(baseName);
|
||||
public void ConfigureCommand(System.Data.Common.DbCommand command) => _inner.ConfigureCommand(command);
|
||||
public void ConfigureParameter(System.Data.Common.DbParameter parameter, object? value) => ((ISqlDialect)_inner).ConfigureParameter(parameter, value);
|
||||
public DriverDataType MapColumnType(string sqlDataType) => _inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
|
||||
// ---- the frozen-peer contract ----
|
||||
|
||||
@@ -13,6 +13,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
/// </summary>
|
||||
public class SqlServerDialectTests
|
||||
{
|
||||
[Fact]
|
||||
public void RowLimit_isTopN_inThePrefixPosition()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("TOP (3) ");
|
||||
d.RowLimitSuffix(3).ShouldBe("");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitPrefix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
|
||||
{
|
||||
|
||||
@@ -87,6 +87,20 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// </summary>
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite has no schema namespace, so the schema level is the single literal
|
||||
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
|
||||
|
||||
Reference in New Issue
Block a user