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:
+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