From 102fa7a6ee119985f7c6f0baf3bea610d7c871bd Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 4 Dec 2023 15:07:42 -0800 Subject: [PATCH] List-to-List MergeManyChangeSets (#780) Implements the MergeManyChangeSets operator for when both the Source and the Child changesets are List ChangeSets. --- src/DynamicData.Tests/Domain/Animal.cs | 21 +- src/DynamicData.Tests/Domain/AnimalOwner.cs | 15 + src/DynamicData.Tests/Domain/Fakers.cs | 53 ++ .../DynamicData.Tests.csproj | 3 +- ....cs => MergeManyChangeSetsCacheFixture.cs} | 18 +- .../List/MergeManyChangeSetsListFixture.cs | 459 ++++++++++++++++++ .../List/Internal/ChangeSetCache.cs | 18 + .../List/Internal/ChangeSetMergeTracker.cs | 83 ++++ .../List/Internal/MergeManyCacheChangeSets.cs | 36 +- .../List/Internal/MergeManyListChangeSets.cs | 46 ++ src/DynamicData/List/ObservableListEx.cs | 23 +- 11 files changed, 717 insertions(+), 58 deletions(-) create mode 100644 src/DynamicData.Tests/Domain/AnimalOwner.cs create mode 100644 src/DynamicData.Tests/Domain/Fakers.cs rename src/DynamicData.Tests/List/{MergeManyCacheChangeSetsFixture.cs => MergeManyChangeSetsCacheFixture.cs} (98%) create mode 100644 src/DynamicData.Tests/List/MergeManyChangeSetsListFixture.cs create mode 100644 src/DynamicData/List/Internal/ChangeSetCache.cs create mode 100644 src/DynamicData/List/Internal/ChangeSetMergeTracker.cs create mode 100644 src/DynamicData/List/Internal/MergeManyListChangeSets.cs diff --git a/src/DynamicData.Tests/Domain/Animal.cs b/src/DynamicData.Tests/Domain/Animal.cs index 4a0d702b2..2bd9f4e53 100644 --- a/src/DynamicData.Tests/Domain/Animal.cs +++ b/src/DynamicData.Tests/Domain/Animal.cs @@ -15,18 +15,11 @@ public enum AnimalFamily Bird } -public class Animal : AbstractNotifyPropertyChanged +public class Animal(string name, string type, AnimalFamily family, bool include = true) : AbstractNotifyPropertyChanged { - private bool _includeInResults; + private bool _includeInResults = include; - public Animal(string name, string type, AnimalFamily family) - { - Name = name; - Type = type; - Family = family; - } - - public AnimalFamily Family { get; } + public AnimalFamily Family { get; } = family; public bool IncludeInResults { @@ -34,7 +27,11 @@ public bool IncludeInResults set => SetAndRaise(ref _includeInResults, value); } - public string Name { get; } + public string Name { get; } = name; + + public string Type { get; } = type; + + public string FormalName => $"{Name} the {Type}"; - public string Type { get; } + public override string ToString() => $"{FormalName} ({Family})"; } diff --git a/src/DynamicData.Tests/Domain/AnimalOwner.cs b/src/DynamicData.Tests/Domain/AnimalOwner.cs new file mode 100644 index 000000000..18a367b07 --- /dev/null +++ b/src/DynamicData.Tests/Domain/AnimalOwner.cs @@ -0,0 +1,15 @@ + +using System; + +namespace DynamicData.Tests.Domain; + +internal class AnimalOwner(string name) : IDisposable +{ + public Guid Id { get; } = Guid.NewGuid(); + + public string Name => name; + + public ISourceList Animals { get; } = new SourceList(); + + public void Dispose() => Animals.Dispose(); +} diff --git a/src/DynamicData.Tests/Domain/Fakers.cs b/src/DynamicData.Tests/Domain/Fakers.cs new file mode 100644 index 000000000..a2e434f5f --- /dev/null +++ b/src/DynamicData.Tests/Domain/Fakers.cs @@ -0,0 +1,53 @@ +using Bogus; + +namespace DynamicData.Tests.Domain; + +internal static class Fakers +{ + const int MinAnimals = 3; +#if DEBUG + const int MaxAnimals = 7; +#else + const int MaxAnimals = 23; +#endif + + private static readonly string[][] AnimalTypeNames = + [ + // Mammal + ["Dog", "Cat", "Ferret", "Hamster", "Gerbil", "Cavie", "Mouse", "Pot-Bellied Pig"], + + // Reptile + ["Corn Snake", "Python", "Gecko", "Skink", "Monitor Lizard", "Chameleon", "Tortoise", "Box Turtle", "Iguana"], + + // Fish + ["Betta", "Goldfish", "Angelfish", "Catfish", "Guppie", "Mollie", "Neon Tetra", "Platie", "Koi"], + + // Amphibian + ["Frog", "Toad", "Salamander"], + + // Bird + ["Parakeet", "Cockatoo", "Parrot", "Finch", "Conure", "Lovebird", "Cockatiel"], + ]; + + public static Faker Animal { get; } = + new Faker() + .CustomInstantiator(faker => + { + var family = faker.PickRandom(); + var type = faker.PickRandom(AnimalTypeNames[(int)family]); + var name = faker.Commerce.ProductAdjective(); + + return new Animal(name, type, family); + }); + + public static Faker AnimalOwner { get; } = + new Faker() + .CustomInstantiator(faker => + { + var result = new AnimalOwner(faker.Person.FullName); + + result.Animals.AddRange(Animal.Generate(faker.Random.Number(MinAnimals, MaxAnimals))); + + return result; + }); +} diff --git a/src/DynamicData.Tests/DynamicData.Tests.csproj b/src/DynamicData.Tests/DynamicData.Tests.csproj index 0bb8253f7..d9100d7f9 100644 --- a/src/DynamicData.Tests/DynamicData.Tests.csproj +++ b/src/DynamicData.Tests/DynamicData.Tests.csproj @@ -1,4 +1,4 @@ - + net6.0;net7.0;net8.0 $(NoWarn);CS0618;CA1801;CA1063;CS8767;CS8602;CS8618;IDE1006 @@ -12,6 +12,7 @@ + diff --git a/src/DynamicData.Tests/List/MergeManyCacheChangeSetsFixture.cs b/src/DynamicData.Tests/List/MergeManyChangeSetsCacheFixture.cs similarity index 98% rename from src/DynamicData.Tests/List/MergeManyCacheChangeSetsFixture.cs rename to src/DynamicData.Tests/List/MergeManyChangeSetsCacheFixture.cs index dc98f8894..a2a1afa0c 100644 --- a/src/DynamicData.Tests/List/MergeManyCacheChangeSetsFixture.cs +++ b/src/DynamicData.Tests/List/MergeManyChangeSetsCacheFixture.cs @@ -11,7 +11,7 @@ namespace DynamicData.Tests.List; -public sealed class MergeManyCacheChangeSetsFixture : IDisposable +public sealed class MergeManyChangeSetsCacheFixture : IDisposable { #if DEBUG const int MarketCount = 3; @@ -36,7 +36,7 @@ public sealed class MergeManyCacheChangeSetsFixture : IDisposable private readonly ChangeSetAggregator _marketListResults; - public MergeManyCacheChangeSetsFixture() + public MergeManyChangeSetsCacheFixture() { _marketListResults = _marketList.Connect().AsAggregator(); } @@ -749,17 +749,3 @@ private void DisposeMarkets() _marketList.Clear(); } } - -internal static class Extensions -{ - public static T With(this T item, Action action) - { - action(item); - return item; - } - - public static IObservable ForceFail(this IObservable source, int count, Exception? e) => - (e is not null) - ? source.Take(count).Concat(Observable.Throw(e)) - : source; -} diff --git a/src/DynamicData.Tests/List/MergeManyChangeSetsListFixture.cs b/src/DynamicData.Tests/List/MergeManyChangeSetsListFixture.cs new file mode 100644 index 000000000..8258c487f --- /dev/null +++ b/src/DynamicData.Tests/List/MergeManyChangeSetsListFixture.cs @@ -0,0 +1,459 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using Bogus; +using DynamicData.Kernel; +using DynamicData.Tests.Domain; +using FluentAssertions; +using Xunit; + +namespace DynamicData.Tests.List; + +public sealed class MergeManyChangeSetsListFixture : IDisposable +{ +#if DEBUG + const int InitialOwnerCount = 7; + const int AddRangeSize = 5; + const int RemoveRangeSize = 3; +#else + const int InitialOwnerCount = 103; + const int AddRangeSize = 53; + const int RemoveRangeSize = 37; +#endif + + private readonly ISourceList _animalOwners = new SourceList(); + private readonly ChangeSetAggregator _animalOwnerResults; + private readonly ChangeSetAggregator _animalResults; + private readonly Randomizer _randomizer; + + public MergeManyChangeSetsListFixture() + { + Randomizer.Seed = new Random(0x12291977); + _randomizer = new Randomizer(); + _animalOwners.AddRange(Fakers.AnimalOwner.Generate(InitialOwnerCount)); + + _animalOwnerResults = _animalOwners.Connect().AsAggregator(); + _animalResults = _animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + } + + [Fact] + public void NullChecks() + { + // Arrange + var emptyChangeSetObs = Observable.Empty>(); + var nullChangeSetObs = (IObservable>)null!; + var emptySelector = new Func>>(i => Observable.Empty>()); + var nullSelector = (Func>>)null!; + + // Act + var checkParam1 = () => nullChangeSetObs.MergeManyChangeSets(emptySelector); + var checkParam2 = () => emptyChangeSetObs.MergeManyChangeSets(nullSelector); + + // Assert + emptyChangeSetObs.Should().NotBeNull(); + emptySelector.Should().NotBeNull(); + nullChangeSetObs.Should().BeNull(); + nullSelector.Should().BeNull(); + + checkParam1.Should().Throw(); + checkParam2.Should().Throw(); + } + + [Fact] + public void ResultContainsAllInitialChildren() + { + // Arrange + + // Act + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount); + CheckResultContents(); + } + + [Fact] + public void ResultContainsChildrenFromParentsAddedWithAddRange() + { + // Arrange + var addThese = Fakers.AnimalOwner.Generate(AddRangeSize); + + // Act + _animalOwners.AddRange(addThese); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount + AddRangeSize); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + AddRangeSize); + addThese.SelectMany(added => added.Animals.Items).ForEach(added => _animalResults.Data.Items.Should().Contain(added)); + CheckResultContents(); + } + + [Fact] + public void ResultContainsChildrenFromParentsAddedWithAdd() + { + // Arrange + var addThis = Fakers.AnimalOwner.Generate(); + + // Act + _animalOwners.Add(addThis); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount + 1); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + addThis.Animals.Items.ForEach(added => _animalResults.Data.Items.Should().Contain(added)); + CheckResultContents(); + } + + [Fact] + public void ResultContainsChildrenFromParentsAddedWithInsert() + { + // Arrange + var insertIndex = _randomizer.Number(_animalOwners.Count); + var insertThis = Fakers.AnimalOwner.Generate(); + + // Act + _animalOwners.Insert(insertIndex, insertThis); + + // Assert + _animalOwners.Items.ElementAt(insertIndex).Should().Be(insertThis); + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount + 1); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + insertThis.Animals.Items.ForEach(added => _animalResults.Data.Items.Should().Contain(added)); + CheckResultContents(); + } + + [Fact] + public void ResultDoesNotContainChildrenFromParentsRemovedWithRemove() + { + // Arrange + var removeThis = _randomizer.ListItem(_animalOwners.Items.ToList()); + + // Act + _animalOwners.Remove(removeThis); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - 1); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + removeThis.Animals.Items.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); + CheckResultContents(); + removeThis.Dispose(); + } + + [Fact] + public void ResultDoesNotContainChildrenFromParentsRemovedWithRemoveAt() + { + // Arrange + var removeIndex = _randomizer.Number(_animalOwners.Count - 1); + var removeThis = _animalOwners.Items.ElementAt(removeIndex); + + // Act + _animalOwners.RemoveAt(removeIndex); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - 1); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + removeThis.Animals.Items.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); + CheckResultContents(); + removeThis.Dispose(); + } + + [Fact] + public void ResultDoesNotContainChildrenFromParentsRemovedWithRemoveRange() + { + // Arrange + var removeIndex = _randomizer.Number(_animalOwners.Count - RemoveRangeSize - 1); + var removeThese = _animalOwners.Items.Skip(removeIndex).Take(RemoveRangeSize); + + // Act + _animalOwners.RemoveRange(removeIndex, RemoveRangeSize); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - RemoveRangeSize); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + RemoveRangeSize); + removeThese.SelectMany(owner => owner.Animals.Items).ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); + CheckResultContents(); + removeThese.ForEach(owner => owner.Dispose()); + } + + [Fact] + public void ResultDoesNotContainChildrenFromParentsRemovedWithRemoveMany() + { + // Arrange + var removeThese = _randomizer.ListItems(_animalOwners.Items.ToList(), RemoveRangeSize); + + // Act + _animalOwners.RemoveMany(removeThese); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount - RemoveRangeSize); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + RemoveRangeSize); + removeThese.SelectMany(owner => owner.Animals.Items).ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); + CheckResultContents(); + removeThese.ForEach(owner => owner.Dispose()); + } + + [Fact] + public void ResultContainsCorrectItemsAfterParentReplacement() + { + // Arrange + var replaceThis = _randomizer.ListItem(_animalOwners.Items.ToList()); + var withThis = Fakers.AnimalOwner.Generate(); + + // Act + _animalOwners.Replace(replaceThis, withThis); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); // Owner Count should not change + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 2); // +2 = 1 Message removing animals from old value, +1 message adding from new value + replaceThis.Animals.Items.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); + withThis.Animals.Items.ForEach(added => _animalResults.Data.Items.Should().Contain(added)); + CheckResultContents(); + replaceThis.Dispose(); + } + + [Fact] + public void ResultEmptyIfSourceIsCleared() + { + // Arrange + var items = _animalOwners.Items.ToList(); + + // Act + _animalOwners.Clear(); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(0); + _animalResults.Data.Count.Should().Be(0); + CheckResultContents(); + items.ForEach(owner => owner.Dispose()); + } + + [Fact] + public void ResultContainsChildrenAddedWithAddRange() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var addThese = Fakers.Animal.Generate(AddRangeSize); + var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.AddRange(addThese); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + addThese.ForEach(animal => _animalResults.Data.Items.Should().Contain(animal)); + _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + AddRangeSize); + CheckResultContents(); + } + + [Fact] + public void ResultContainsChildrenAddedWithAdd() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var addThis = Fakers.Animal.Generate(); + var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.Add(addThis); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + _animalResults.Data.Items.Should().Contain(addThis); + _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + 1); + CheckResultContents(); + } + + [Fact] + public void ResultContainsChildrenAddedWithInsert() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var insertIndex = _randomizer.Number(randomOwner.Animals.Items.Count()); + var insertThis = Fakers.Animal.Generate(); + var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.Insert(insertIndex, insertThis); + + // Assert + randomOwner.Animals.Items.ElementAt(insertIndex).Should().Be(insertThis); + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + _animalResults.Data.Items.Should().Contain(insertThis); + _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount + 1); + CheckResultContents(); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemove() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var removeThis = _randomizer.ListItem(randomOwner.Animals.Items.ToList()); + var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.Remove(removeThis); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + _animalResults.Data.Items.Should().NotContain(removeThis); + _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount - 1); + CheckResultContents(); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemoveAt() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var removeIndex = _randomizer.Number(randomOwner.Animals.Count - 1); + var removeThis = randomOwner.Animals.Items.ElementAt(removeIndex); + var initialCount = _animalOwners.Items.Sum(owner => owner.Animals.Count); + + // Act + randomOwner.Animals.RemoveAt(removeIndex); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + _animalResults.Data.Items.Should().NotContain(removeThis); + _animalOwners.Items.Sum(owner => owner.Animals.Count).Should().Be(initialCount - 1); + CheckResultContents(); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemoveRange() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var removeCount = _randomizer.Number(1, randomOwner.Animals.Count - 1); + var removeIndex = _randomizer.Number(randomOwner.Animals.Count - removeCount - 1); + var removeThese = randomOwner.Animals.Items.Skip(removeIndex).Take(removeCount); + + // Act + randomOwner.Animals.RemoveRange(removeIndex, removeCount); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + removeThese.ForEach(removed => randomOwner.Animals.Items.Should().NotContain(removed)); + CheckResultContents(); + } + + [Fact] + public void ResultDoesNotContainChildrenRemovedWithRemoveMany() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var removeCount = _randomizer.Number(1, randomOwner.Animals.Count - 1); + var removeThese = _randomizer.ListItems(randomOwner.Animals.Items.ToList(), removeCount); + + // Act + randomOwner.Animals.RemoveMany(removeThese); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + removeThese.ForEach(removed => randomOwner.Animals.Items.Should().NotContain(removed)); + CheckResultContents(); + } + + [Fact] + public void ResultContainsCorrectItemsAfterChildReplacement() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var replaceThis = _randomizer.ListItem(randomOwner.Animals.Items.ToList()); + var withThis = Fakers.Animal.Generate(); + + // Act + randomOwner.Animals.Replace(replaceThis, withThis); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + randomOwner.Animals.Items.Should().NotContain(replaceThis); + randomOwner.Animals.Items.Should().Contain(withThis); + CheckResultContents(); + } + + [Fact] + public void ResultContainsCorrectItemsAfterChildClear() + { + // Arrange + var randomOwner = _randomizer.ListItem(_animalOwners.Items.ToList()); + var removedAnimals = randomOwner.Animals.Items.ToList(); + + // Act + randomOwner.Animals.Clear(); + + // Assert + _animalOwnerResults.Data.Count.Should().Be(InitialOwnerCount); + _animalResults.Messages.Count.Should().Be(InitialOwnerCount + 1); + randomOwner.Animals.Count.Should().Be(0); + removedAnimals.ForEach(removed => _animalResults.Data.Items.Should().NotContain(removed)); + CheckResultContents(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ResultCompletesOnlyWhenSourceAndAllChildrenComplete(bool completeSource, bool completeChildren) + { + // Arrange + + // Act + _animalOwners.Items.Skip(completeChildren ? 0 : 1).ForEach(owner => owner.Dispose()); + if (completeSource) + { + _animalOwners.Dispose(); + } + + // Assert + _animalOwnerResults.IsCompleted.Should().Be(completeSource); + _animalResults.IsCompleted.Should().Be(completeSource && completeChildren); + } + + [Fact] + public void ResultFailsIfSourceFails() + { + // Arrange + var expectedError = new Exception("Expected"); + var throwObservable = Observable.Throw>(expectedError); + using var results = _animalOwners.Connect().Concat(throwObservable).MergeManyChangeSets(owner => owner.Animals.Connect()).AsAggregator(); + + // Act + _animalOwners.Dispose(); + + // Assert + results.Exception.Should().Be(expectedError); + } + + private void CheckResultContents() + { + var expectedOwners = _animalOwners.Items.ToList(); + var expectedAnimals = expectedOwners.SelectMany(owner => owner.Animals.Items).ToList(); + + // These should be subsets of each other, so check one subset and the size + expectedOwners.Should().BeSubsetOf(_animalOwnerResults.Data.Items); + _animalOwnerResults.Data.Items.Count().Should().Be(expectedOwners.Count); + + // These should be subsets of each other, so check one subset and the size + expectedAnimals.Should().BeSubsetOf(_animalResults.Data.Items); + _animalResults.Data.Items.Count().Should().Be(expectedAnimals.Count); + } + + public void Dispose() + { + _animalOwners.Items.ForEach(owner => owner.Dispose()); + _animalOwnerResults.Dispose(); + _animalResults.Dispose(); + _animalOwners.Dispose(); + } +} diff --git a/src/DynamicData/List/Internal/ChangeSetCache.cs b/src/DynamicData/List/Internal/ChangeSetCache.cs new file mode 100644 index 000000000..af54f7489 --- /dev/null +++ b/src/DynamicData/List/Internal/ChangeSetCache.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; + +namespace DynamicData.List.Internal; + +internal class ChangeSetCache + where TObject : notnull +{ + public ChangeSetCache(IObservable> source) => + Source = source.Do(List.Clone); + + public List List { get; } = []; + + public IObservable> Source { get; } +} diff --git a/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs b/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs new file mode 100644 index 000000000..465837582 --- /dev/null +++ b/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace DynamicData.List.Internal; + +internal class ChangeSetMergeTracker + where TObject : notnull +{ + private readonly ChangeAwareList _resultList = new(); + + public void ProcessChangeSet(IChangeSet changes, IObserver> observer) + { + foreach (var change in changes) + { + switch (change.Reason) + { + case ListChangeReason.Add: + OnItemAdded(change.Item); + break; + + case ListChangeReason.Remove: + OnItemRemoved(change.Item); + break; + + case ListChangeReason.Replace: + OnItemReplaced(change.Item); + break; + + case ListChangeReason.Refresh: + OnItemRefreshed(change.Item); + break; + + case ListChangeReason.AddRange: + OnRangeAdded(change.Range); + break; + + case ListChangeReason.RemoveRange: + OnRangeRemoved(change.Range); + break; + + case ListChangeReason.Clear: + OnClear(change); + break; + + case ListChangeReason.Moved: + // Ignore move changes because nothing can be done due to the indexes being different in the merged result + break; + } + } + + EmitChanges(observer); + } + + public void RemoveItems(IEnumerable removeItems, IObserver> observer) + { + _resultList.Remove(removeItems); + EmitChanges(observer); + } + + private void OnClear(Change change) => _resultList.ClearOrRemoveMany(change); + + private void OnItemAdded(ItemChange item) => _resultList.Add(item.Current); + + private void OnItemRefreshed(ItemChange item) => _resultList.Refresh(item.Current); + + private void OnItemRemoved(ItemChange item) => _resultList.Remove(item.Current); + + private void OnItemReplaced(ItemChange item) => _resultList.ReplaceOrAdd(item.Previous.Value, item.Current); + + private void OnRangeAdded(RangeChange range) => _resultList.AddRange(range); + + private void OnRangeRemoved(RangeChange range) => _resultList.Remove(range); + + private void EmitChanges(IObserver> observer) + { + var changeSet = _resultList.CaptureChanges(); + if (changeSet.Count != 0) + { + observer.OnNext(changeSet); + } + } +} diff --git a/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs b/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs index c6bc42f64..482885f43 100644 --- a/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs +++ b/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs @@ -11,51 +11,34 @@ namespace DynamicData.List.Internal; /// /// Operator that is similiar to MergeMany but intelligently handles Cache ChangeSets. /// -internal sealed class MergeManyCacheChangeSets +internal sealed class MergeManyCacheChangeSets(IObservable> source, Func>> changeSetSelector, IEqualityComparer? equalityComparer, IComparer? comparer) where TObject : notnull where TDestination : notnull where TDestinationKey : notnull { - private readonly IObservable> _source; - - private readonly Func>> _changeSetSelector; - - private readonly IComparer? _comparer; - - private readonly IEqualityComparer? _equalityComparer; - - public MergeManyCacheChangeSets(IObservable> source, Func>> selector, IEqualityComparer? equalityComparer, IComparer? comparer) - { - _source = source; - _changeSetSelector = selector; - _comparer = comparer; - _equalityComparer = equalityComparer; - } - - public IObservable> Run() - { - return Observable.Create>( + public IObservable> Run() => + Observable.Create>( observer => { var locker = new object(); // Transform to an observable list of merge containers. - var sourceListOfCaches = _source - .Transform(obj => new ChangeSetCache(_changeSetSelector(obj))) + var sourceListOfCaches = source + .Transform(obj => new ChangeSetCache(changeSetSelector(obj))) .Synchronize(locker) .AsObservableList(); var shared = sourceListOfCaches.Connect().Publish(); - // this is manages all of the changes - var changeTracker = new ChangeSetMergeTracker(() => sourceListOfCaches.Items.ToArray(), _comparer, _equalityComparer); + // This is manages all of the changes + var changeTracker = new ChangeSetMergeTracker(() => sourceListOfCaches.Items.ToArray(), comparer, equalityComparer); - // when a source item is removed, all of its sub-items need to be removed + // When a source item is removed, all of its sub-items need to be removed var removedItems = shared .OnItemRemoved(mc => changeTracker.RemoveItems(mc.Cache.KeyValues, observer)) .Subscribe(); - // merge the items back together + // Merge the items back together var allChanges = shared.MergeMany(mc => mc.Source) .Synchronize(locker) .Subscribe( @@ -65,5 +48,4 @@ public IObservable> Run() return new CompositeDisposable(sourceListOfCaches, allChanges, removedItems, shared.Connect()); }); - } } diff --git a/src/DynamicData/List/Internal/MergeManyListChangeSets.cs b/src/DynamicData/List/Internal/MergeManyListChangeSets.cs new file mode 100644 index 000000000..4b06cc6a8 --- /dev/null +++ b/src/DynamicData/List/Internal/MergeManyListChangeSets.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; + +namespace DynamicData.List.Internal; + +internal class MergeManyListChangeSets(IObservable> source, Func>> selector) + where TObject : notnull + where TDestination : notnull +{ + public IObservable> Run() => + Observable.Create>( + observer => + { + var locker = new object(); + + // Transform to an observable list of cached lists + var sourceListofLists = source + .Transform(obj => new ChangeSetCache(selector(obj))) + .Synchronize(locker) + .AsObservableList(); + + var shared = sourceListofLists.Connect().Publish(); + + // This is manages all of the changes + var changeTracker = new ChangeSetMergeTracker(); + + // Merge the items back together + var allChanges = shared.MergeMany(mc => mc.Source.RemoveIndex()) + .Synchronize(locker) + .Subscribe( + changes => changeTracker.ProcessChangeSet(changes, observer), + observer.OnError, + observer.OnCompleted); + + // When a source item is removed, all of its sub-items need to be removed + var removedItems = shared + .OnItemRemoved(mc => changeTracker.RemoveItems(mc.List, observer)) + .Subscribe(); + + return new CompositeDisposable(sourceListofLists, allChanges, removedItems, shared.Connect()); + }); +} diff --git a/src/DynamicData/List/ObservableListEx.cs b/src/DynamicData/List/ObservableListEx.cs index f2ae426f5..4bac2eb73 100644 --- a/src/DynamicData/List/ObservableListEx.cs +++ b/src/DynamicData/List/ObservableListEx.cs @@ -1202,7 +1202,26 @@ public static IObservable> MergeChangeSets - /// Operator similiar to MergeMany except it is ChangeSet aware. It uses to transform each item in the source into a child and merges the result children together into a single stream of ChangeSets that correctly handles multiple Keys and removal of the parent items. + /// Operator similiar to MergeMany except it is List ChangeSet aware. It uses to transform each item in the source into a child and merges the result children together into a single stream of ChangeSets that correctly handles removal of the parent items and other changes to the source list. + /// + /// The type of the object. + /// The type of the destination. + /// The Source Observable ChangeSet. + /// Factory Function used to create child changesets. + /// The result from merging the children list changesets together. + /// Parameter was null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector) + where TObject : notnull + where TDestination : notnull + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (observableSelector == null) throw new ArgumentNullException(nameof(observableSelector)); + + return new MergeManyListChangeSets(source, observableSelector).Run(); + } + + /// + /// Operator similiar to MergeMany except it is Cache ChangeSet aware. It uses to transform each item in the source into a child and merges the result children together into a single stream of ChangeSets that correctly handles multiple Keys and removal of the parent items. /// /// The type of the object. /// The type of the destination. @@ -1225,7 +1244,7 @@ public static IObservable> MergeManyCh } /// - /// Operator similiar to MergeMany except it is ChangeSet aware. It uses to transform each item in the source into a child and merges the result children together into a single stream of ChangeSets that correctly handles multiple Keys and removal of the parent items. + /// Operator similiar to MergeMany except it is Cache ChangeSet aware. It uses to transform each item in the source into a child and merges the result children together into a single stream of ChangeSets that correctly handles multiple Keys and removal of the parent items. /// /// The type of the object. /// The type of the destination.