using System.Data.Common; using System.Globalization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// Pins the bind-parameter ceiling (Driver.Sql-002 / Driver.Sql.Dialect-002): a key-value group holding /// more distinct keyValues than the dialect's is split /// into several plans over the same group key, rather than emitting one statement the provider /// rejects. /// Why this is a correctness test and not a tuning one. Over the ceiling the provider refuses /// the statement after the connection opened, so every tag in the group publishes /// BadCommunicationError on every poll, permanently — and a key-value (EAV) staging table is /// precisely the shape that attracts thousands of tags on one table, which the group-key rule folds into /// one group. /// A single group emitting several plans needs no reader change: SqlPollReader runs /// one command per plan and slices each plan's own Members back, so the last test here drives the /// whole read path over a real database to prove exactly that. /// public sealed class SqlGroupPlannerChunkingTests { private static SqlTagDefinition KvTag(string name, string keyValue, string table = "dbo.TagValues") => new(name, SqlTagModel.KeyValue, table, KeyColumn: "tag_name", KeyValue: keyValue, ValueColumn: "num_value"); // ---- the shipped ceilings ---- /// /// Oracle's real ceiling, on the real dialect: an IN expression list may hold 1000 entries, so /// 1001 distinct keys cannot be one statement. This is the shape the finding describes — no test-only /// dialect involved. /// [Fact] public void AGroupBeyondTheOracleInListLimit_isSplit_ratherThanEmittedAsOneRejectedStatement() { var dialect = new OracleDialect(); var tags = Enumerable.Range(0, 1001) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, dialect); plans.Count.ShouldBe(2); plans.ShouldAllBe(p => p.Parameters.Count <= 1000); plans.Sum(p => p.Parameters.Count).ShouldBe(1001); } /// /// The same for SQL Server, whose 2100-parameter command cap is the other limit the finding names. /// [Fact] public void AGroupBeyondTheSqlServerParameterCap_isSplit() { var dialect = new SqlServerDialect(); var tags = Enumerable.Range(0, dialect.MaxBindParameters + 1) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, dialect); plans.Count.ShouldBe(2); plans.ShouldAllBe(p => p.Parameters.Count <= dialect.MaxBindParameters); } // ---- how a split group is shaped ---- [Fact] public void EachChunkBindsAtMostTheCeiling_andTheChunksCoverEveryKeyExactlyOnce() { var tags = Enumerable.Range(0, 7) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(3)); plans.Count.ShouldBe(3); plans.Select(p => p.Parameters.Count).ShouldBe(new[] { 3, 3, 1 }); plans.SelectMany(p => p.Parameters) .ShouldBe(Enumerable.Range(0, 7).Select(i => (object?)("Key" + i.ToString(CultureInfo.InvariantCulture)))); } /// /// Every member must land in the chunk that binds its own key — a member whose key is bound /// by a different plan reads a result set that cannot contain its row and publishes /// BadNoData forever, which is the quiet way to get this wrong. /// [Fact] public void EveryMemberLandsInTheChunkThatBindsItsOwnKey() { var tags = Enumerable.Range(0, 7) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(3)); foreach (var plan in plans) foreach (var member in plan.Members) plan.Parameters.ShouldContain(member.KeyValue); // …and no member is dropped or duplicated across the split. plans.SelectMany(p => p.Members).Select(m => m.Name) .ShouldBe(tags.Select(t => t.Name), ignoreOrder: true); plans.Sum(p => p.Members.Count).ShouldBe(tags.Length); } /// /// Two tags on one keyValue bind one parameter and must stay in one chunk — splitting them /// would bind the same key twice and cost a whole extra round-trip for a row already fetched. /// [Fact] public void MembersSharingAKeyValue_stayInTheSameChunk() { var plans = SqlGroupPlanner.Plan( [ KvTag("A", "K1"), KvTag("B", "K2"), KvTag("C", "K1"), KvTag("D", "K3"), ], new BindCeilingDialect(2)); plans.Count.ShouldBe(2); plans[0].Parameters.ShouldBe(new object[] { "K1", "K2" }); plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" }); plans[1].Parameters.ShouldBe(new object[] { "K3" }); plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "D" }); } /// /// Each chunk is its own command, so its markers restart at k0 — carrying the global index /// forward would emit IN (@k3, @k4, @k5) against a three-parameter command, which is legal but /// makes a plan's SQL unreadable next to its own parameter list. /// [Fact] public void EachChunkNumbersItsMarkersFromZero_andTheSqlMatchesItsOwnParameters() { var tags = Enumerable.Range(0, 5) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(2)); foreach (var plan in plans) { plan.ParameterNames.ShouldBe( Enumerable.Range(0, plan.Parameters.Count) .Select(i => "@k" + i.ToString(CultureInfo.InvariantCulture))); plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")"); } } /// All chunks of one group carry that group's key — the split is an execution detail, not a regrouping. [Fact] public void EveryChunkOfOneGroupCarriesTheSameGroupKey() { var tags = Enumerable.Range(0, 5) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(2)); plans.Count.ShouldBeGreaterThan(1); plans.Select(p => p.GroupKey).Distinct().Count().ShouldBe(1); } /// /// The falsifiability control. Chunking must not fragment an ordinary group: a planner that split /// eagerly (or off a stale count) would pass every test above and quietly turn one round-trip per /// source into N. /// [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] public void AGroupAtOrUnderTheCeiling_stillEmitsExactlyOnePlan(int distinctKeys) { var tags = Enumerable.Range(0, distinctKeys) .Select(i => KvTag("T" + i.ToString(CultureInfo.InvariantCulture), "Key" + i.ToString(CultureInfo.InvariantCulture))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(3)); plans.Count.ShouldBe(1); plans[0].Members.Count.ShouldBe(distinctKeys); } /// /// Two groups that each need splitting stay separate, and the chunks of one never mix with the other's /// — the split happens inside a group, after grouping. /// [Fact] public void TwoOversizedGroups_areSplitIndependently() { var tags = Enumerable.Range(0, 3).Select(i => KvTag("A" + i, "K" + i, "dbo.First")) .Concat(Enumerable.Range(0, 3).Select(i => KvTag("B" + i, "K" + i, "dbo.Second"))) .ToArray(); var plans = SqlGroupPlanner.Plan(tags, new BindCeilingDialect(2)); plans.Count.ShouldBe(4); // Group order still follows the input order of each group's first member (dbo.First, then dbo.Second), // and a group's own chunks stay adjacent and in key order. plans.Select(p => p.Members[0].Table).ShouldBe(["dbo.First", "dbo.First", "dbo.Second", "dbo.Second"]); } /// A wide-row plan binds at most one value, so a ceiling of 1 must not disturb it. [Fact] public void AWideRowPlan_isUnaffectedByTheCeiling() { var tag = new SqlTagDefinition("A", SqlTagModel.WideRow, "dbo.LatestStatus", ColumnName: "oven_temp", RowSelectorColumn: "station_id", RowSelectorValue: "7"); var plans = SqlGroupPlanner.Plan([tag], new BindCeilingDialect(1)); plans.Count.ShouldBe(1); plans[0].Parameters.Count.ShouldBe(1); } /// /// A dialect reporting a ceiling below 1 cannot be chunked to — it must fail loudly rather than loop /// forever or emit an empty IN () list, which is a syntax error on every backend. /// [Theory] [InlineData(0)] [InlineData(-1)] public void ADialectReportingANonPositiveCeiling_isRejected(int ceiling) => Should.Throw( () => SqlGroupPlanner.Plan([KvTag("A", "K1")], new BindCeilingDialect(ceiling))); // ---- the whole read path, over a real database ---- /// /// Drives over the SQLite fixture with a ceiling of 1, so three keys become /// three plans, and asserts every tag still reads its own value. This is what proves several plans per /// group need no reader change — the claim the chunking design rests on. /// [Fact] public async Task ThePollReader_readsEveryTagCorrectly_whenOneGroupIsSplitAcrossSeveralPlans() { using var fixture = new SqlitePollFixture(); SqlTagDefinition[] tags = [ Fixture("Speed", SqlitePollFixture.PresentKey), Fixture("Pressure", SqlitePollFixture.SecondPresentKey), Fixture("Missing", SqlitePollFixture.AbsentKey), ]; var reader = new SqlPollReader( fixture.Factory, fixture.ConnectionString, new BindCeilingDialect(1), commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15), maxConcurrentGroups: 4, nullIsBad: false, resolve: reference => tags.FirstOrDefault(t => t.Name == reference)); var snapshots = await reader.ReadAsync(["Speed", "Pressure", "Missing"], CancellationToken.None); snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue); snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); } private static SqlTagDefinition Fixture(string name, string keyValue) => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, ValueColumn: SqlitePollFixture.ValueColumn, TimestampColumn: SqlitePollFixture.TimestampColumn); /// /// A test-only dialect that reports a caller-chosen /// and delegates everything else to — so a chunked plan can be asserted on /// at a readable size and actually executed against the fixture database. /// A decorator rather than a subclass, for the reason the other fakes here give: /// is sealed, and new-hiding a member would leave interface dispatch /// calling the original, so the substitution would silently not happen. /// private sealed class BindCeilingDialect(int maxBindParameters) : ISqlDialect { private static readonly SqliteDialect Inner = new(); public int MaxBindParameters { get; } = maxBindParameters; public SqlProvider Provider => Inner.Provider; public DbProviderFactory Factory => Inner.Factory; public string LivenessSql => Inner.LivenessSql; public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix; public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix; public string ListSchemasSql => Inner.ListSchemasSql; public string DefaultSchemaSql => Inner.DefaultSchemaSql; public string ListTablesSql => Inner.ListTablesSql; public string ListColumnsSql => Inner.ListColumnsSql; public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName); public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName); public void ConfigureCommand(DbCommand command) => Inner.ConfigureCommand(command); public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident); public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType); } }