Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Extensions/Xtensive.Orm.BulkOperations.Tests/ContainsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,5 +164,40 @@ public void TestManyGuids()
Assert.That(session.Query.All<TagType>().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<TagType>()
.Where(t => ids.Contains(t.Id))
.Set(t => t.ProjectedValueAdjustment, 2)
.Update();
Assert.That(updatedRows, Is.EqualTo(100));
Assert.That(session.Query.All<TagType>().Count(t => t.ProjectedValueAdjustment == 2 && t.Id <= 200), Is.EqualTo(100));
Assert.That(session.Query.All<TagType>().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<TagType>()
.Where(t => ids.Contains(t.Guid))
.Set(t => t.ProjectedValueAdjustment, 2)
.Update();
Assert.That(updatedRows, Is.EqualTo(100));
Assert.That(session.Query.All<TagType>().Count(t => t.ProjectedValueAdjustment == 2 && t.Id <= 200), Is.EqualTo(100));
Assert.That(session.Query.All<TagType>().Count(t => t.ProjectedValueAdjustment == -1 && t.Id > 700), Is.EqualTo(1));
}
}
}
}
89 changes: 89 additions & 0 deletions Orm/Xtensive.Orm.Tests.Core/Rse/RecordSetHeaderTest.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Regression tests for <see cref="RecordSetHeader"/> ensuring that intermediate
/// header construction during query translation does not eagerly trip the
/// <c>DO_MAX_1000_COLUMNS</c> guard in <see cref="TupleDescriptor.LazyData"/>.
/// The guard must still fire if/when the resulting descriptor's lazy data
/// is actually accessed (e.g. at materialization).
/// </summary>
[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<Column>(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<NotSupportedException>(() => _ = descriptor.ValuesLength);
}
}
}
5 changes: 3 additions & 2 deletions Orm/Xtensive.Orm/Orm/Providers/SqlCompiler.Include.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ internal protected override SqlIncludeProvider VisitInclude(IncludeProvider prov
IncludeProvider provider, IReadOnlyList<TypeMapping> mappings, Func<ParameterContext, object> valueAccessor,
IReadOnlyList<SqlExpression> 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);
}

Expand Down
2 changes: 0 additions & 2 deletions Orm/Xtensive.Orm/Orm/Rse/RecordSetHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 8 additions & 20 deletions Orm/Xtensive.Orm/Tuples/TupleDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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> -> 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;
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Version.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<PropertyGroup>
<DoVersion>7.3.18</DoVersion>
<DoVersionSuffix>servicetitan</DoVersionSuffix>
<DoVersionSuffix>servicetitan-upstream-fix-3</DoVersionSuffix>
</PropertyGroup>

</Project>
Loading