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 0d933d07..f586bbf6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs @@ -51,6 +51,21 @@ public sealed record SqlQueryPlan( IReadOnlyList Members, IReadOnlyList SelectedColumns) { + // 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 + // mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary. + /// The parameter markers in , aligned with . + public IReadOnlyList ParameterNames { get; } = [.. ParameterNames]; + + /// The values to bind, aligned with . + public IReadOnlyList Parameters { get; } = [.. Parameters]; + + /// The tags this plan feeds, in input order; duplicates preserved. + public IReadOnlyList Members { get; } = [.. Members]; + + /// The bare SELECT-list column names, in result-set ordinal order. + public IReadOnlyList SelectedColumns { get; } = [.. SelectedColumns]; + /// /// The key column all members are matched on — for any model but /// . Uniform across by the group-key invariant, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs new file mode 100644 index 00000000..b76ddd8a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs @@ -0,0 +1,34 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +public class SqlQueryPlanImmutabilityTests +{ + // A plan is documented as cacheable across polls. If it stayed aliased to the planner's working + // List, a consumer downcast could corrupt every later poll that reused it. + [Fact] + public void Plan_doesNotAliasTheCallersLists() + { + var names = new List { "@k0" }; + var values = new List { "v" }; + var members = new List + { + new("raw", SqlTagModel.KeyValue, "t") { KeyColumn = "k", KeyValue = "v", ValueColumn = "c" }, + }; + var selected = new List { "k", "c" }; + + var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected); + + names.Add("@k1"); + values.Add("other"); + members.Add(members[0]); + selected.Add("extra"); + + plan.ParameterNames.Count.ShouldBe(1); + plan.Parameters.Count.ShouldBe(1); + plan.Members.Count.ShouldBe(1); + plan.SelectedColumns.Count.ShouldBe(2); + } +}