Skip to content

Commit c9550aa

Browse files
committed
Correct restore of order of items in target collections
1 parent eafbf00 commit c9550aa

File tree

2 files changed

+124
-31
lines changed

2 files changed

+124
-31
lines changed

src/AutoMapper.Collection.Tests/MapCollectionWithEqualityTests.cs

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ protected virtual void ConfigureMapper(IMapperConfigurationExpression cfg)
1515
cfg.CreateMap<ThingDto, Thing>().EqualityComparison((dto, entity) => dto.ID == entity.ID);
1616
}
1717

18+
1819
[Fact]
1920
public void Should_Keep_Existing_List()
2021
{
@@ -34,6 +35,38 @@ public void Should_Keep_Existing_List()
3435
mapper.Map(dtos, items).Should().BeSameAs(items);
3536
}
3637

38+
[Fact]
39+
public void Should_Reorder_Destination_To_Match_Source_Order()
40+
{
41+
var mapper = CreateMapper(ConfigureMapper);
42+
43+
var dtos = new List<ThingDto>
44+
{
45+
new ThingDto { ID = 2, Title = "test2" },
46+
new ThingDto { ID = 1, Title = "test1" },
47+
new ThingDto { ID = 3, Title = "test3" }
48+
};
49+
50+
var one = new Thing { ID = 1, Title = "one-initial" };
51+
var two = new Thing { ID = 2, Title = "two-initial" };
52+
var three = new Thing { ID = 3, Title = "three-initial" };
53+
var items = new List<Thing> { one, two, three };
54+
55+
mapper.Map(dtos, items);
56+
57+
// Expect the destination to be reordered to match source while preserving instances
58+
items.Should().HaveCount(3);
59+
items[0].Should().BeSameAs(two);
60+
items[1].Should().BeSameAs(one);
61+
items[2].Should().BeSameAs(three);
62+
items.Select(i => i.ID).Should().Equal(2, 1, 3);
63+
64+
// And Titles should be updated from the source
65+
items[0].Title.Should().Be("test2");
66+
items[1].Title.Should().Be("test1");
67+
items[2].Title.Should().Be("test3");
68+
}
69+
3770
[Fact]
3871
public void Should_Update_Existing_Item()
3972
{
@@ -66,19 +99,6 @@ public void Should_Be_Fast_With_Large_Lists()
6699
mapper.Map(dtos, items.ToList()).Should().HaveElementAt(0, items.First());
67100
}
68101

69-
[Fact]
70-
public void Should_Be_Fast_With_Large_Reversed_Lists()
71-
{
72-
var mapper = CreateMapper(ConfigureMapper);
73-
74-
var dtos = new object[100000].Select((_, i) => new ThingDto { ID = i }).ToList();
75-
dtos.Reverse();
76-
77-
var items = new object[100000].Select((_, i) => new Thing { ID = i }).ToList();
78-
79-
mapper.Map(dtos, items.ToList()).Should().HaveElementAt(0, items.First());
80-
}
81-
82102
[Fact]
83103
public void Should_Be_Fast_With_Large_Lists_MultiProperty_Mapping()
84104
{

src/AutoMapper.Collection/Mappers/EquivalentExpressionAddRemoveCollectionMapper.cs

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,39 +25,112 @@ public static TDestination Map<TSource, TSourceItem, TDestination, TDestinationI
2525
return destination;
2626
}
2727

28-
var destList = destination.ToLookup(x => equivalentComparer.GetHashCode(x)).ToDictionary(x => x.Key, x => x.ToList());
28+
// Build a lookup of existing destination items by the equivalency hash
29+
var destLookup = destination.ToLookup(x => equivalentComparer.GetHashCode(x)).ToDictionary(x => x.Key, x => x.ToList());
2930

30-
var items = source.Select(x =>
31+
// We'll collect the items in the exact order of the source, preserving existing instances
32+
var ordered = new List<TDestinationItem>();
33+
var toAdd = new List<TDestinationItem>();
34+
35+
foreach (var src in source)
3136
{
32-
var sourceHash = equivalentComparer.GetHashCode(x);
37+
var sourceHash = equivalentComparer.GetHashCode(src);
3338

34-
var item = default(TDestinationItem);
35-
if (destList.TryGetValue(sourceHash, out var itemList))
39+
TDestinationItem match = default;
40+
if (destLookup.TryGetValue(sourceHash, out var candidates))
3641
{
37-
item = itemList.FirstOrDefault(dest => equivalentComparer.IsEquivalent(x, dest));
38-
if (item != null)
42+
match = candidates.FirstOrDefault(dest => equivalentComparer.IsEquivalent(src, dest));
43+
if (match != null)
3944
{
40-
itemList.Remove(item);
45+
// Reserve this destination instance and update it
46+
candidates.Remove(match);
47+
context.Mapper.Map(src, match);
48+
ordered.Add(match);
49+
continue;
4150
}
4251
}
43-
return new { SourceItem = x, DestinationItem = item };
44-
});
4552

46-
foreach (var keypair in items)
53+
// No match found: create a new destination item
54+
var newItem = (TDestinationItem)context.Mapper.Map(src, null, typeof(TSourceItem), typeof(TDestinationItem));
55+
toAdd.Add(newItem);
56+
ordered.Add(newItem);
57+
}
58+
59+
// Remove any remaining destination items that were not matched
60+
foreach (var removedItem in destLookup.SelectMany(x => x.Value))
4761
{
48-
if (keypair.DestinationItem == null)
62+
destination.Remove(removedItem);
63+
}
64+
65+
// Ensure all new items are part of the destination collection before reordering
66+
foreach (var add in toAdd)
67+
{
68+
if (!destination.Contains(add))
4969
{
50-
destination.Add((TDestinationItem)context.Mapper.Map(keypair.SourceItem, null, typeof(TSourceItem), typeof(TDestinationItem)));
70+
destination.Add(add);
5171
}
52-
else
72+
}
73+
74+
// Reorder destination to match the 'ordered' sequence while preserving the collection instance
75+
if (destination is IList<TDestinationItem> list)
76+
{
77+
var oc = list as System.Collections.ObjectModel.ObservableCollection<TDestinationItem>;
78+
for (int i = 0; i < ordered.Count; i++)
5379
{
54-
context.Mapper.Map(keypair.SourceItem, keypair.DestinationItem);
80+
var target = ordered[i];
81+
if (i < list.Count && ReferenceEquals(list[i], target))
82+
{
83+
continue;
84+
}
85+
86+
// Find the current index of the target item, if it exists
87+
int currentIndex = -1;
88+
for (int j = i + 1; j < list.Count; j++)
89+
{
90+
if (ReferenceEquals(list[j], target))
91+
{
92+
currentIndex = j;
93+
break;
94+
}
95+
}
96+
97+
if (currentIndex >= 0)
98+
{
99+
if (oc != null)
100+
{
101+
// Use Move to raise a single Move event
102+
oc.Move(currentIndex, i);
103+
}
104+
else
105+
{
106+
// Move existing item to the desired index
107+
var item = list[currentIndex];
108+
list.RemoveAt(currentIndex);
109+
list.Insert(i, item);
110+
}
111+
}
112+
else
113+
{
114+
// Insert the new item at the correct position
115+
list.Insert(i, target);
116+
}
55117
}
56-
}
57118

58-
foreach (var removedItem in destList.SelectMany(x => x.Value))
119+
}
120+
else
59121
{
60-
destination.Remove(removedItem);
122+
// Fallback for non-IList collections: remove and re-add in order
123+
// Note: This may lose ordering guarantees for certain collection types that don't preserve insertion order
124+
// but provides best-effort behavior.
125+
var existing = destination.ToList();
126+
foreach (var item in existing)
127+
{
128+
destination.Remove(item);
129+
}
130+
foreach (var item in ordered)
131+
{
132+
destination.Add(item);
133+
}
61134
}
62135

63136
return destination;

0 commit comments

Comments
 (0)