From b66d88d37431764c88f7019db7d734c177dd86ee Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Mon, 27 Apr 2026 16:39:37 +0400 Subject: [PATCH 1/7] Fix QueryTranslationException from RecordSetHeader.Join dead code Remove an unused `CreateTupleDescriptor(newColumns)` call in `RecordSetHeader.Join`. After upstream PR #248 changed the return statement to use the lazy two-arg `TupleDescriptor(a, b)` constructor, the local `newTupleDescriptor` became dead code. Combined with the later move of the >1000-column guard into `LazyData` (PR #204), this dead call eagerly tripped `NotSupportedException` during translation of complex queries whose transient joined headers exceed 1000 columns, even when the materialized recordset (after column pruning) would not. Made-with: Cursor --- Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs b/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs index 2b7a935e1b..a3114015b4 100644 --- a/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs +++ b/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs @@ -132,8 +132,6 @@ public RecordSetHeader Join(RecordSetHeader joined) newColumns[j++] = c.Clone((ColNum) (columnCount + c.Index)); } - var newTupleDescriptor = CreateTupleDescriptor(newColumns); - var columnGroupCount = ColumnGroups.Count; var groups = new ColumnGroup[columnGroupCount + joined.ColumnGroups.Count]; j = 0; From 5b4928ce6fd64b7633bd65812c1b3b1a118b1339 Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Mon, 27 Apr 2026 16:40:58 +0400 Subject: [PATCH 2/7] Bump DO version --- Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.props b/Version.props index 3884d1feb7..5e6477077c 100644 --- a/Version.props +++ b/Version.props @@ -3,7 +3,7 @@ 7.3.18 - servicetitan + servicetitan-upstream-fix From b5dea7dde6e1141bf289928f368fc95447397e34 Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Mon, 27 Apr 2026 18:21:11 +0400 Subject: [PATCH 3/7] Fix QueryTranslationException from RecordSetHeader.Add eager validation Switch `RecordSetHeader.CreateTupleDescriptor` (used by both `Add` overloads) from `TupleDescriptor.Create` to `TupleDescriptor.CreateFromNormalized`, matching the lazy normalization pattern already used by `Select` and the upstream-optimized `Join`. The eager `Create` factory routes to the single-arg `TupleDescriptor` constructor that synchronously materializes `LazyData` and trips the >1000-column guard during query translation, even when the resulting header is transient and gets pruned before execution. This affected `IncludeProvider`-driven translations of `Contains`-based predicates over already-wide intermediate headers (e.g. `e.statusesList.Contains(e.Membership.Status)` within complex multi-Select/SelectMany/GroupJoin chains). Add `Xtensive.Orm.Tests.Core/Rse/RecordSetHeaderTest.cs` with regression tests covering both `Add` overloads, `Join`, and the preserved runtime guard via `LazyData` access. Made-with: Cursor --- .../Rse/RecordSetHeaderTest.cs | 89 +++++++++++++++++++ Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 Orm/Xtensive.Orm.Tests.Core/Rse/RecordSetHeaderTest.cs diff --git a/Orm/Xtensive.Orm.Tests.Core/Rse/RecordSetHeaderTest.cs b/Orm/Xtensive.Orm.Tests.Core/Rse/RecordSetHeaderTest.cs new file mode 100644 index 0000000000..6a993c3c7e --- /dev/null +++ b/Orm/Xtensive.Orm.Tests.Core/Rse/RecordSetHeaderTest.cs @@ -0,0 +1,89 @@ +// Copyright (C) 2026 Xtensive LLC. +// This code is distributed under MIT license terms. +// See the License.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Xtensive.Orm.Rse; +using Xtensive.Tuples; + +namespace Xtensive.Orm.Tests.Core.Rse +{ + /// + /// Regression tests for ensuring that intermediate + /// header construction during query translation does not eagerly trip the + /// DO_MAX_1000_COLUMNS guard in . + /// The guard must still fire if/when the resulting descriptor's lazy data + /// is actually accessed (e.g. at materialization). + /// + [TestFixture] + public class RecordSetHeaderTest + { + private static RecordSetHeader CreateHeader(int columnCount, string prefix = "c") + { + var fieldTypes = new Type[columnCount]; + var columns = new Column[columnCount]; + for (var i = 0; i < columnCount; i++) { + fieldTypes[i] = typeof(int); + columns[i] = new SystemColumn(prefix + i, (ColNum) i, typeof(int)); + } + return new RecordSetHeader(TupleDescriptor.CreateFromNormalized(fieldTypes), columns); + } + + [Test] + public void Add_DoesNotEagerlyValidateColumnCount() + { + var header = CreateHeader(1000); + + // Adding a single column would push the resulting descriptor over the + // 1000-column threshold. Header construction must succeed lazily. + var extended = header.Add(new SystemColumn("extra", 1000, typeof(int))); + + Assert.That(extended.Columns.Count, Is.EqualTo(1001)); + Assert.That(extended.TupleDescriptor.Count, Is.EqualTo(1001)); + } + + [Test] + public void AddRange_DoesNotEagerlyValidateColumnCount() + { + var header = CreateHeader(800); + var extra = new List(300); + for (var i = 0; i < 300; i++) { + extra.Add(new SystemColumn("extra" + i, (ColNum) (800 + i), typeof(int))); + } + + var extended = header.Add(extra); + + Assert.That(extended.Columns.Count, Is.EqualTo(1100)); + Assert.That(extended.TupleDescriptor.Count, Is.EqualTo(1100)); + } + + [Test] + public void Join_DoesNotEagerlyValidateColumnCount() + { + var left = CreateHeader(700, "l"); + var right = CreateHeader(700, "r"); + + var joined = left.Join(right); + + Assert.That(joined.Columns.Count, Is.EqualTo(1400)); + Assert.That(joined.TupleDescriptor.Count, Is.EqualTo(1400)); + } + + [Test] + public void OversizedDescriptor_StillThrowsOnLazyDataAccess() + { + // Building a descriptor with > 1000 fields via the lazy path must succeed, + // but accessing the lazy data (e.g. ValuesLength) must surface the guard. + var fieldTypes = new Type[1500]; + for (var i = 0; i < fieldTypes.Length; i++) { + fieldTypes[i] = typeof(int); + } + var descriptor = TupleDescriptor.CreateFromNormalized(fieldTypes); + + Assert.That(descriptor.Count, Is.EqualTo(1500)); + Assert.Throws(() => _ = descriptor.ValuesLength); + } + } +} diff --git a/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs b/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs index a3114015b4..2b9ccba768 100644 --- a/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs +++ b/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs @@ -267,7 +267,7 @@ private static TupleDescriptor CreateTupleDescriptor(IReadOnlyList newCo for (var i = 0; i < n; i++) { newFieldTypes[i] = newColumns[i].Type; } - return TupleDescriptor.Create(newFieldTypes); + return TupleDescriptor.CreateFromNormalized(newFieldTypes); } /// From 22785fac30e91b530ce8471bc735eea2d1fd553a Mon Sep 17 00:00:00 2001 From: Sergey Naumenko <152863015+snaumenko-st@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:23:55 +0400 Subject: [PATCH 4/7] Update DoVersionSuffix version --- Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.props b/Version.props index 5e6477077c..9bf8e467af 100644 --- a/Version.props +++ b/Version.props @@ -3,7 +3,7 @@ 7.3.18 - servicetitan-upstream-fix + servicetitan-upstream-fix-2 From 39eaf245c2640d4be7f4c7ba17241328e818d858 Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Mon, 27 Apr 2026 19:17:56 +0400 Subject: [PATCH 5/7] Fix SQL 2100-parameter limit in Bulk Update with large Contains Auto+TVP path of SqlDml.Variant lost its TvpTypeMapping because the complex-condition branch always allocated a fresh QueryParameterBinding, discarding the TVP binding the caller had just created. Reuse the incoming binding so CommandFactory can switch to a single table-valued parameter once the collection grows past MaxNumberOfConditions. Adds regression tests covering Bulk Update Contains over 2200 ids/guids. Made-with: Cursor --- .../ContainsTest.cs | 35 +++++++++++++++++++ .../Orm/Providers/SqlCompiler.Include.cs | 5 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Extensions/Xtensive.Orm.BulkOperations.Tests/ContainsTest.cs b/Extensions/Xtensive.Orm.BulkOperations.Tests/ContainsTest.cs index 2464ec503c..a79f9d4f26 100644 --- a/Extensions/Xtensive.Orm.BulkOperations.Tests/ContainsTest.cs +++ b/Extensions/Xtensive.Orm.BulkOperations.Tests/ContainsTest.cs @@ -164,5 +164,40 @@ public void TestManyGuids() Assert.That(session.Query.All().Count(t => t.ProjectedValueAdjustment == -1 && t.Id > 700), Is.EqualTo(1)); } } + + // Regression: large collections in Bulk Update Contains used to trip the SQL + // Server 2100-parameter limit because SqlDml.Variant branches did not share + // their TVP binding. Both branches must reuse the same QueryRowFilterParameterBinding. + [Test] + public void TestManyIdsContains() + { + using (var session = Domain.OpenSession()) + using (var tx = session.OpenTransaction()) { + var ids = tagIds.Concat(Enumerable.Range(4000, 2200).Select(o => (long) o)).ToArray(); + var updatedRows = session.Query.All() + .Where(t => ids.Contains(t.Id)) + .Set(t => t.ProjectedValueAdjustment, 2) + .Update(); + Assert.That(updatedRows, Is.EqualTo(100)); + Assert.That(session.Query.All().Count(t => t.ProjectedValueAdjustment == 2 && t.Id <= 200), Is.EqualTo(100)); + Assert.That(session.Query.All().Count(t => t.ProjectedValueAdjustment == -1 && t.Id > 700), Is.EqualTo(1)); + } + } + + [Test] + public void TestManyGuidsContains() + { + using (var session = Domain.OpenSession()) + using (var tx = session.OpenTransaction()) { + var ids = guids.Concat(Enumerable.Range(4000, 2200).Select(IntToGuid)).ToArray(); + var updatedRows = session.Query.All() + .Where(t => ids.Contains(t.Guid)) + .Set(t => t.ProjectedValueAdjustment, 2) + .Update(); + Assert.That(updatedRows, Is.EqualTo(100)); + Assert.That(session.Query.All().Count(t => t.ProjectedValueAdjustment == 2 && t.Id <= 200), Is.EqualTo(100)); + Assert.That(session.Query.All().Count(t => t.ProjectedValueAdjustment == -1 && t.Id > 700), Is.EqualTo(1)); + } + } } } diff --git a/Orm/Xtensive.Orm/Orm/Providers/SqlCompiler.Include.cs b/Orm/Xtensive.Orm/Orm/Providers/SqlCompiler.Include.cs index 8daf199296..82b24df325 100644 --- a/Orm/Xtensive.Orm/Orm/Providers/SqlCompiler.Include.cs +++ b/Orm/Xtensive.Orm/Orm/Providers/SqlCompiler.Include.cs @@ -91,8 +91,9 @@ internal protected override SqlIncludeProvider VisitInclude(IncludeProvider prov IncludeProvider provider, IReadOnlyList mappings, Func valueAccessor, IReadOnlyList sourceColumns, QueryParameterBinding binding = null) { - var filterTupleDescriptor = provider.FilteredTupleDescriptor; - binding = new QueryRowFilterParameterBinding(mappings, valueAccessor, null, false); + // In the Auto+TVP path both SqlDml.Variant branches must share the TVP binding + // so CommandFactory can switch to a single table-valued parameter at runtime. + binding ??= new QueryRowFilterParameterBinding(mappings, valueAccessor, null, false); return (SqlDml.DynamicFilter(binding, provider.FilteredColumns.Select(index => sourceColumns[index]).ToArray()), binding); } From 7af9e419eb7ca155623782db1f3b1b3eb7d8bf47 Mon Sep 17 00:00:00 2001 From: snaumenko-st Date: Mon, 27 Apr 2026 20:47:23 +0400 Subject: [PATCH 6/7] Fix botched merge of TupleDescriptor single-arg ctor in PR #481 Restore PR #204's lazy normalize-only body. The upstream merge kept the eager switch+Configure block but routed writes through the lazy `Data` property, forcing `LazyData` materialization (and the DO_MAX_1000_COLUMNS guard) at construction time. Deferring layout to `LazyData` removes the eager 1000-column trip during query translation and obviates the `RecordSetHeader.Add` workaround. Made-with: Cursor --- Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs | 2 +- Orm/Xtensive.Orm/Tuples/TupleDescriptor.cs | 28 ++++++--------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs b/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs index 2b9ccba768..a3114015b4 100644 --- a/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs +++ b/Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs @@ -267,7 +267,7 @@ private static TupleDescriptor CreateTupleDescriptor(IReadOnlyList newCo for (var i = 0; i < n; i++) { newFieldTypes[i] = newColumns[i].Type; } - return TupleDescriptor.CreateFromNormalized(newFieldTypes); + return TupleDescriptor.Create(newFieldTypes); } /// diff --git a/Orm/Xtensive.Orm/Tuples/TupleDescriptor.cs b/Orm/Xtensive.Orm/Tuples/TupleDescriptor.cs index badf68eb62..45f187be8b 100644 --- a/Orm/Xtensive.Orm/Tuples/TupleDescriptor.cs +++ b/Orm/Xtensive.Orm/Tuples/TupleDescriptor.cs @@ -337,27 +337,15 @@ internal TupleDescriptor(TupleDescriptor a, TupleDescriptor b) private TupleDescriptor(Type[] fieldTypes) { - var fieldCount = fieldTypes.Length; FieldTypes = fieldTypes; - - switch (fieldCount) { - case 0: - Data.ValuesLength = 0; - Data.ObjectsLength = 0; - return; - case 1: - TupleLayout.ConfigureLen1(ref FieldTypes[0], - ref FieldDescriptors[0], - out Data.ValuesLength, out Data.ObjectsLength); - break; - case 2: - TupleLayout.ConfigureLen2(FieldTypes, - ref FieldDescriptors[0], ref FieldDescriptors[1], - out Data.ValuesLength, out Data.ObjectsLength); - break; - default: - TupleLayout.Configure(FieldTypes, FieldDescriptors, out Data.ValuesLength, out Data.ObjectsLength); - break; + // Eagerly normalize field types in place (Nullable -> T, enum -> underlying, ...) + // so FieldTypes is canonical at construction. Heavy packed-layout configuration + // (and the DO_MAX_1000_COLUMNS guard) is deferred to LazyData materialization. + for (int i = 0, n = fieldTypes.Length; i < n; ++i) { + ref var fieldType = ref fieldTypes[i]; + if (TupleLayout.ValueFieldAccessorResolver.GetValue(fieldType) is { } valueAccessor) { + fieldType = valueAccessor.FieldType; + } } } From 8186d7087f83283fdee0ea59ea745f8c59987cd4 Mon Sep 17 00:00:00 2001 From: Sergey Naumenko <152863015+snaumenko-st@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:12:02 +0400 Subject: [PATCH 7/7] Update DoVersionSuffix version --- Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.props b/Version.props index 9bf8e467af..ad30d96c8b 100644 --- a/Version.props +++ b/Version.props @@ -3,7 +3,7 @@ 7.3.18 - servicetitan-upstream-fix-2 + servicetitan-upstream-fix-3