diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md index 7b7ad3ae..ffa68922 100644 --- a/docs/drivers/Sql.md +++ b/docs/drivers/Sql.md @@ -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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs index c144ec62..e1069372 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs @@ -177,6 +177,31 @@ public interface ISqlDialect /// string SingleRowLimitSuffix { get; } + /// + /// The position's fragment for an -row limit — + /// T-SQL's "TOP (n) "; empty for a dialect that limits at the end. Same trailing-space rule. + /// + /// + /// 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 N + 1 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. + /// + /// The row limit; at least 1. + /// The fragment, possibly empty. + /// is below 1. + string RowLimitPrefix(int rows); + + /// + /// The position's fragment for an -row limit — + /// " LIMIT n" / " FETCH FIRST n ROWS ONLY"; empty for a dialect that limits after + /// SELECT. Same leading-space rule. See . + /// + /// The row limit; at least 1. + /// The fragment, possibly empty. + /// is below 1. + string RowLimitSuffix(int rows); + /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters. string ListSchemasSql { get; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs index 2dc72a83..1789959f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs @@ -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"; + /// + public string RowLimitPrefix(int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + return string.Empty; + } + + /// + // 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"; + } + /// // 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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs index b332d912..6809b328 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs @@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect /// public string SingleRowLimitSuffix => " LIMIT 1"; + /// + public string RowLimitPrefix(int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + return string.Empty; + } + + /// + 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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs index 9db9848c..61057407 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs @@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect /// public string SingleRowLimitSuffix => string.Empty; + /// + // 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) + ") "; + } + + /// + public string RowLimitSuffix(int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + return string.Empty; + } + /// public string ListSchemasSql => "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index 945fc32b..4a8936c9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -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. /// /// The snapshots the reader returned for one poll. - internal void ObservePollOutcome(IReadOnlyList snapshots) + /// + /// Groups the database answered but the driver refused (an over-cap result set, R06:S-1). Their + /// members carry , 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. + /// + internal void ObservePollOutcome(IReadOnlyList 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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs index 9d1142b8..31c77351 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs @@ -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); } /// @@ -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)) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs index da6ecfb5..25e70a39 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs @@ -162,11 +162,24 @@ public sealed class SqlPollReader : IDisposable /// The database could not be reached (opening a connection failed). public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) + => (await ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false)).Snapshots; + + /// + /// 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 + /// — 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. + /// + /// The RawPaths to read. + /// The caller's token; see . + /// The snapshots and the answered-refusal count. + public async Task ReadDetailedAsync( + IReadOnlyList 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); } - /// Plans the resolved definitions, runs every group concurrently, and slices the results back. - private async Task ReadGroupsAsync( + /// + /// Plans the resolved definitions, runs every group concurrently, and slices the results back. + /// Returns how many groups came back over their row cap. + /// + private async Task ReadGroupsAsync( List definitions, Dictionary> 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; } /// @@ -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(); - 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 ORDER BY, 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. - /// Why the where-pair form is deliberately NOT given the dialect's single-row limit. A - /// TOP 1 with no ORDER BY 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 - /// (Rows.Count > 1), converting a loud misconfiguration into a permanently silent one — - /// precisely the failure mode this class's summary calls its top risk. The topByTimestamp - /// form keeps its row limit because there the ORDER BY at least fixes which 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. + /// Why the where-pair form is capped at TWO rows, not one (R06:S-1). A TOP 1 with no + /// ORDER BY 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 + /// BadConfigurationError in MaterialiseAsync, 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. /// ⚠ The topByTimestamp answer is only defined when the ordering column is unique /// ORDER BY ts DESC 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, []); + /// + /// 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. + /// + public static GroupResult RowCapExceeded { get; } = + new(SqlStatusCodes.BadConfigurationError, NoRows, NoColumns, []); + /// The status every member inherits when is false. public uint FailureStatus { get; } @@ -835,6 +886,14 @@ public sealed class SqlPollReader : IDisposable } } +/// One poll's snapshots plus the answer evidence they cannot carry themselves. +/// One snapshot per requested reference, at the same index. +/// +/// Groups the database answered with more rows than their plan can use; their members carry +/// . +/// +public sealed record SqlPollResult(IReadOnlyList Snapshots, int RowCapExceededGroups); + /// /// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read /// them back with. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs index 8e20c989..333a762c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs @@ -55,6 +55,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// The bare (unquoted) column names in 's SELECT list, in order and distinct — the /// reader's map from result-set ordinal to source column. /// +/// +/// The most rows this plan can USE — its distinct key count (key-value) or 1 (a where-pair wide row); +/// when the statement already limits itself (topByTimestamp). +/// asks for RowCap + 1, 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 +/// BadConfigurationError instead of materialising every matching row on every poll (R06:S-1). +/// public sealed record SqlQueryPlan( SqlTagModel Model, object GroupKey, @@ -63,7 +70,8 @@ public sealed record SqlQueryPlan( IReadOnlyList ParameterBindNames, IReadOnlyList Parameters, IReadOnlyList Members, - IReadOnlyList SelectedColumns) + IReadOnlyList 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 holding a List is downcastable, and a diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/OracleFlavouredSqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/OracleFlavouredSqliteDialect.cs index a6868aa3..0987f4b8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/OracleFlavouredSqliteDialect.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/OracleFlavouredSqliteDialect.cs @@ -38,6 +38,10 @@ public sealed class OracleFlavouredSqliteDialect : ISqlDialect /// 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; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs index f514b1be..939a267b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs @@ -77,6 +77,10 @@ public sealed class ColonMarkerDialect : ISqlDialect /// public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix; + public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows); + + public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows); + /// public string ListSchemasSql => Colonize(Inner.ListSchemasSql); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs index a0050cf7..70629ae4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs @@ -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(() => d.RowLimitSuffix(0)); + } + [Fact] public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs index 2f56b7d1..de681931 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs @@ -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(() => d.RowLimitSuffix(0)); + } + [Fact] public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogDefaultSchemaTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogDefaultSchemaTests.cs index 0c72ca4a..2f8a1f47 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogDefaultSchemaTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogDefaultSchemaTests.cs @@ -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; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs index f0e5d373..017c0bd9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs @@ -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; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs index 492cc05d..6dd21ce9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs @@ -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); } + /// + /// R06:S-1. 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. + /// + [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] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerChunkingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerChunkingTests.cs index d8bc6c8a..dd6920c2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerChunkingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerChunkingTests.cs @@ -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; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs index 344f4447..058ea516 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs @@ -29,6 +29,38 @@ public class SqlGroupPlannerTests RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue, RowSelectorTopByTimestamp: topByTimestamp); + // ---- R06:S-1: the row cap ---- + + /// + /// 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. + /// + [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 ---- diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs index 87a0d02a..4181449c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs @@ -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); + } + + /// + /// R06:S-1. 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// SQLite with the row-limit fragments blanked — the "provider cannot spell a limit" shape. + 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 ---- diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs index 901382f1..912a8ee6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -13,6 +13,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// 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(() => d.RowLimitPrefix(0)); + } + [Fact] public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs index 62b9f3f4..20dded60 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs @@ -87,6 +87,20 @@ public sealed class SqliteDialect : ISqlDialect /// public string SingleRowLimitSuffix => " LIMIT 1"; + /// + public string RowLimitPrefix(int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + return string.Empty; + } + + /// + public string RowLimitSuffix(int rows) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + /// /// SQLite has no schema namespace, so the schema level is the single literal /// — enough to give the browser a real root to expand.