From 7e7ae24317269a33114ee70df0b66f64da06cd1f Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Thu, 28 May 2026 09:03:52 -0700 Subject: [PATCH 01/14] Wait for quiescence in MergeManyChangeSets stress tests (#1100) * Make MergeManyChangeSetsCacheSourceCompare stress test deterministic MultiThreadedStressTest(10, 50) fails intermittently in CI with two prices present in market.PricesCache.Items but missing from the live aggregator. The two affected prices have the latest timestamps in the batch, which is the signature of a race during high-contention production. Bogus.Randomizer wraps System.Random. When constructed with a seed, the randomizer stores the random in a protected localSeed field and bypasses its internal Locker on every generator call. The test shares one seeded Randomizer across many parallel producer threads: - Directly via _randomizer.Number / .Bool / .TimeSpan / .Interval - Indirectly via _marketFaker.WithSeed(_randomizer), since every Faker.Generate call routes through the same randomizer Concurrent calls into the underlying System.Random corrupt its internal state, producing values inconsistent with what a serialized run would produce. That is sufficient to explain the observed asymmetry between the post-hoc PricesCache snapshot and the live aggregator stream. Introduce SynchronizedRandomizer, a Randomizer subclass that replaces the protected localSeed field with a LockedRandom (a Random subclass that serializes every virtual method on an internal lock). The seed and method contracts are unchanged; the wrapper only adds synchronization. Apply it to the failing fixture. Other Randomizer uses across the test project remain unchanged for now; they are either single-threaded or have not exhibited flake symptoms. Verified: 20 consecutive runs of the fixture pass at MaxParallelThreads=16, zero failures. * Wait for quiescence in MergeManyChangeSets stress tests The post-#1079 cache delivery model decouples mutation from notification: AddOrUpdate enqueues a notification and returns; the actual delivery to subscribers runs later on whichever thread wins the drain. That removed the cross-cache deadlock the old Synchronize(lock) shape produced, but it opened a small window between mutation and observed delivery. Tests that compare a live aggregator's view against the cache's current Items at assert time can see disagreement during that window. The source-compare fixture already adopted the right shape: var merged = source.MergeManyChangeSets(...).Publish(); var cacheCompleted = merged.LastOrDefaultAsync().ToTask(); using var local = merged.AsAggregator(); using var connect = merged.Connect(); ... await cacheCompleted; CheckResultContents(..., local); Port the same pattern to the cache and list MergeManyChangeSets stress fixtures. The local aggregator now sits on the Publish chain so it shares the completion task; the await before CheckResultContents pins the quiescence point. Also delete the SynchronizedRandomizer change made earlier on this branch. Bogus.Randomizer takes a process-wide lock on Locker.Value for every generator call regardless of whether localSeed is set, so the wrapper was addressing a non-problem. --------- Co-authored-by: Darrin Cullop (cherry picked from commit 8033135afb1173fbae06a2c5c5a4a7ae6b5f7db6) --- .../Cache/MergeManyChangeSetsCacheFixture.cs | 11 ++++++++--- .../Cache/MergeManyChangeSetsListFixture.cs | 15 +++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs index 37ca6b198..949ec35bf 100644 --- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsCacheFixture.cs @@ -5,6 +5,7 @@ using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using Bogus; using DynamicData.Kernel; @@ -90,10 +91,11 @@ IObservable AddRemovePrices(Market market, int priceCount, int para .Parallelize(priceCount, parallel, obs => obs.StressAddRemove(market.PricesCache, _ => GetRemoveTime(), scheduler)) .Finally(market.PricesCache.Dispose); - var merged = _marketCache.Connect().MergeManyChangeSets(market => market.LatestPrices); - using var priceResults = merged.AsAggregator(); - + var merged = _marketCache.Connect().MergeManyChangeSets(market => market.LatestPrices).Publish(); var adding = true; + var cacheCompleted = merged.LastOrDefaultAsync().ToTask(); + using var priceResults = merged.AsAggregator(); + using var connect = merged.Connect(); // Start asynchrononously modifying the parent list and the child lists using var addingSub = AddRemoveStress(marketCount, priceCount, Environment.ProcessorCount, TaskPoolScheduler.Default) @@ -119,6 +121,9 @@ IObservable AddRemovePrices(Market market, int priceCount, int para } while (adding); + // Wait for the source cache to finish delivering all notifications. + await cacheCompleted; + // Verify the results CheckResultContents(_marketCacheResults, priceResults); } diff --git a/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs b/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs index f7a4aa2a5..49f566907 100644 --- a/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs +++ b/src/DynamicData.Tests/Cache/MergeManyChangeSetsListFixture.cs @@ -5,6 +5,7 @@ using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using Bogus; using DynamicData.Kernel; @@ -86,9 +87,11 @@ IObservable AddRemoveAnimals(AnimalOwner owner, int animalCount, int par .Parallelize(animalCount, parallel, obs => obs.StressAddRemove(owner.Animals, _ => GetRemoveTime(), scheduler)) .Finally(owner.Animals.Dispose); - var mergeAnimals = _animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()); - + var mergeAnimals = _animalOwners.Connect().MergeManyChangeSets(owner => owner.Animals.Connect()).Publish(); var addingAnimals = true; + var cacheCompleted = mergeAnimals.LastOrDefaultAsync().ToTask(); + using var animalResults = mergeAnimals.AsAggregator(); + using var connect = mergeAnimals.Connect(); // Start asynchrononously modifying the parent list and the child lists using var addAnimals = AddRemoveAnimalsStress(ownerCount, animalCount, Environment.ProcessorCount, TaskPoolScheduler.Default) @@ -114,8 +117,12 @@ IObservable AddRemoveAnimals(AnimalOwner owner, int animalCount, int par } while (addingAnimals); - // Verify the results - CheckResultContents(); + // Wait for the source cache to finish delivering all notifications. + await cacheCompleted; + + // Verify the results against the aggregator wired into the same Publish chain + // that cacheCompleted observes. + CheckResultContents(_animalOwners.Items, _animalOwnerResults, animalResults); } [Fact] From f3e4c0467ae7e0232a6587862cce1a34c0cd90d2 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Thu, 28 May 2026 23:03:59 -0700 Subject: [PATCH 02/14] Make SizeLimit tests deterministic by deduplicating generator output (#1098) RandomPersonGenerator emits Person rows drawn from a finite name pool (~21 girls + ~30 boys cross-joined with 24 lastnames squared). Person.Key is Person.Name, so two independent .Take(10) calls can produce overlapping keys with non-trivial probability. When they collide, the second batch's AddOrUpdate produces 9 Adds + 1 Update instead of 10 Adds, breaking the per-message assertions in: - InvokeLimitSizeToWhenOverLimit - AddMoreThanLimitInBatched Both tests now draw 60 candidates up front, dedupe by Key, take the first 20, and split into two non-overlapping batches of 10. Verified: 50/50 consecutive runs of SizeLimitFixture pass with no failures. Co-authored-by: Darrin Cullop (cherry picked from commit 87edfa930005654a02a26a0f300bfe1a95ca7374) --- .../Cache/SizeLimitFixture.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs index bbc67f04d..378b7fa6d 100644 --- a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs +++ b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs @@ -72,8 +72,13 @@ public void AddMoreThanLimit() [Fact] public void AddMoreThanLimitInBatched() { - _source.AddOrUpdate(_generator.Take(10).ToArray()); - _source.AddOrUpdate(_generator.Take(10).ToArray()); + // _generator.Take(N) draws random Person rows from a finite name pool; a second + // Take(10) call can produce keys that collide with the first batch, turning an + // Add into an Update and breaking the per-message Adds count. Draw a larger pool + // up front, dedupe by Key, then split into two non-overlapping batches of 10. + var people = _generator.Take(60).DistinctBy(p => p.Key).Take(20).ToArray(); + _source.AddOrUpdate(people.Take(10).ToArray()); + _source.AddOrUpdate(people.Skip(10).Take(10).ToArray()); _scheduler.Start(); @@ -96,12 +101,17 @@ public void InvokeLimitSizeToWhenOverLimit() var removesTriggered = false; var subscriber = _source.LimitSizeTo(10, _scheduler).Subscribe(removes => { removesTriggered = true; }); - _source.AddOrUpdate(_generator.Take(10).ToArray()); + // _generator.Take(N) draws random Person rows from a finite name pool; a second + // Take(10) call can produce keys that collide with the first batch, turning an + // Add into an Update and breaking the per-message Adds count. Draw a larger pool + // up front, dedupe by Key, then split into two non-overlapping batches of 10. + var people = _generator.Take(60).DistinctBy(p => p.Key).Take(20).ToArray(); + _source.AddOrUpdate(people.Take(10).ToArray()); _scheduler.AdvanceBy(TimeSpan.FromMilliseconds(150).Ticks); removesTriggered.Should().BeFalse(); - _source.AddOrUpdate(_generator.Take(10).ToArray()); + _source.AddOrUpdate(people.Skip(10).Take(10).ToArray()); _scheduler.AdvanceBy(TimeSpan.FromMilliseconds(150).Ticks); From 7885e3bfcc11e2671e7100eecc55fa0b8105bfc5 Mon Sep 17 00:00:00 2001 From: Jake Meiergerd Date: Fri, 29 May 2026 18:03:06 -0700 Subject: [PATCH 03/14] Rewrote testing for the cache variant of the AutoRefresh() and AutoRefreshOnObservable() operators, in accordance with #1014, and in a preliminary effort to resolve #1099. (#1101) (cherry picked from commit 3de2fada7da97b86f171f7157bce3eca1adf5148) --- .../Cache/AutoRefreshFixture.Base.cs | 617 ++++++++++++++ ...AutoRefreshFixture.WithPropertyAccessor.cs | 71 ++ ...oRefreshFixture.WithoutPropertyAccessor.cs | 64 ++ .../Cache/AutoRefreshFixture.cs | 157 ++-- .../AutoRefreshOnObservableFixture.Base.cs | 777 ++++++++++++++++++ .../AutoRefreshOnObservableFixture.WithKey.cs | 34 + ...toRefreshOnObservableFixture.WithoutKey.cs | 34 + .../Cache/AutoRefreshOnObservableFixture.cs | 79 ++ 8 files changed, 1724 insertions(+), 109 deletions(-) create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs create mode 100644 src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs new file mode 100644 index 000000000..ebdc26950 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.Base.cs @@ -0,0 +1,617 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Reactive.Testing; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshFixture +{ + public abstract class Base + { + [Fact] + public void ChangeSetBufferIsGiven_PropertyChangedNotificationsAreBufferedOnScheduler() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + changeSetBuffer: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish property change notification) + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the property change notification should have been buffered"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, within buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the buffer window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, to buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "a buffer window expired"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a property change notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish property change notification) + ++item1.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the property change notification should have been buffered"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, within buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the buffer window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish additional property change notification) + ++item3.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the property change notification should have been buffered"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, to buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "a buffer window expired"); + results.RecordedChangeSets.Skip(2).First().Count.Should().Be(2, "2 items published a property change notification"); + results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(2, "2 items published a property change notification"); + results.RecordedChangeSets.Skip(2).First().Select(change => change.Current).Should().BeEquivalentTo(new[] { item1, item3 }, "items #2 and #3 published property change notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (normal refresh) + source.Refresh(item2); + + // Normal refreshes should not be buffered + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "one source operation was performed"); + results.RecordedChangeSets.Skip(3).First().Count.Should().Be(1, "1 item was refreshed, within the source"); + results.RecordedChangeSets.Skip(3).First().Refreshes.Should().Be(1, "1 item was refreshed, within the source"); + results.RecordedChangeSets.Skip(3).First().First().Current.Should().Be(item2, "item #2 was refreshed, within the source"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ItemIsAdded_SubscribesToPropertyChanged() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + item1.HasSubscriptions.Should().BeTrue("the PropertyChanged event should be subscribed to, for each added item"); + item2.HasSubscriptions.Should().BeTrue("the PropertyChanged event should be subscribed to, for each added item"); + item3.HasSubscriptions.Should().BeTrue("the PropertyChanged event should be subscribed to, for each added item"); + } + + [Fact] + public void ItemIsMoved_NotificationPropagates() + { + // Setup + using var source = new Subject>(); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + var items = new [] { item1, item2, item3 }; + + var initialChangeset = new ChangeSet() + { + new Change(reason: ChangeReason.Add, key: item1.Id, current: item1, index: 0), + new Change(reason: ChangeReason.Add, key: item2.Id, current: item2, index: 1), + new Change(reason: ChangeReason.Add, key: item3.Id, current: item3, index: 2) + }; + + // UUT Initialization + using var subscription = BuildUut(source.Prepend(initialChangeset)) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added to the source"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + items, + options => options.WithStrictOrdering(), + "item indexes should propagate"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + source.OnNext(new ChangeSet() + { + new Change( + key: item3.Id, + current: item3, + currentIndex: 0, + previousIndex: 2) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "an item was moved within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + new[] { item3, item1, item2 }, + options => options.WithStrictOrdering(), + "an item was moved within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ItemIsRefreshed_NotificationPropagates() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + // UUT Action + source.Refresh(item2); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item was refreshed within the source"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item was refreshed within the source"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 was refreshed within the source"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ItemIsRemoved_UnsubscribesFromPropertyChanged() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + source.Remove(item2); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was removed from the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + item2.HasSubscriptions.Should().BeFalse("removing an item should trigger unsubscription from its reevaluator"); + item1.HasSubscriptions.Should().BeTrue("the item was not removed from the source"); + item3.HasSubscriptions.Should().BeTrue("the item was not removed from the source"); + } + + [Fact] + public void ItemIsUpdated_ReSubscribesToPropertyChanged() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + var item4 = new Item() { Id = 2 }; + source.AddOrUpdate(item4); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was replaced within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + item2.HasSubscriptions.Should().BeFalse("replacing an item should trigger unsubscription from its reevaluator"); + item4.HasSubscriptions.Should().BeTrue("adding an item should invoke its reevaluator and subscribe to it"); + item1.HasSubscriptions.Should().BeTrue("the item was not removed from the source"); + item3.HasSubscriptions.Should().BeTrue("the item was not removed from the source"); + } + + [Fact] + public void PropertyChangedOccurs_ItemRefreshes() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a property change notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void PropertyChangeThrottleIsGiven_PropertyChangedNotificationsAreThrottledByScheduler() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + propertyChangeThrottle: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish property change notification) + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the throttle window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish additional property change notification, immediately) + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the throttle window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time to end of throttle window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "the throttle window ended"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published property change notifications"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published property change notifications"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published property change notifications"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish property change notification) + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the throttle window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish additional property change notification, within throttle window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); + ++item2.Value; + scheduler.AdvanceBy(1); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the throttle window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time to end of original throttle window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the throttle window should have been extended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time to end of throttle window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(25).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "the throttle window ended"); + results.RecordedChangeSets.Skip(2).First().Count.Should().Be(1, "1 item published property change notifications"); + results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(1, "1 item published property change notifications"); + results.RecordedChangeSets.Skip(2).First().First().Current.Should().Be(item2, "item #2 published property change notifications"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void SourceCompletesWhenEmpty_CompletionPropagates(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + + // UUT Initialization & Action + if (notificationStrategy is NotificationStrategy.Immediate) + source.Complete(); + + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeTrue("all notification sources have completed"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void SourceCompletesWhenNotEmpty_CompletionDoesNotPropagate(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization & Action (source completion) + if (notificationStrategy is NotificationStrategy.Immediate) + source.Complete(); + + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("PropertyChanged events can still publish notifications"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void SourceFails_ErrorPropagates(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var error = new Exception("Test"); + + + // UUT Initialization & Action + if (notificationStrategy is NotificationStrategy.Immediate) + source.SetError(error); + + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + source.SetError(error); + + results.Error.Should().Be(error, "upstream errors should propagate downstream"); + if (notificationStrategy is NotificationStrategy.Immediate) + results.RecordedChangeSets.Should().BeEmpty("an error occurred before the initial changeset"); + else + { + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + } + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => BuildUut(source: null!)) + .Should() + .Throw(); + + [Fact] + public void SubscriptionIsDisposed_SubscriptionDisposalPropagates() + { + // Setup + using var source = new Subject>(); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + var initialChangeset = new ChangeSet() + { + new Change(reason: ChangeReason.Add, key: item1.Id, current: item1), + new Change(reason: ChangeReason.Add, key: item2.Id, current: item2), + new Change(reason: ChangeReason.Add, key: item3.Id, current: item3) + }; + + + // UUT Initialization + using var subscription = BuildUut(source.Prepend(initialChangeset)) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + subscription.Dispose(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + source.HasObservers.Should().BeFalse("subscription disposal should propagate"); + item1.HasSubscriptions.Should().BeFalse("subscription disposal should propagate"); + item2.HasSubscriptions.Should().BeFalse("subscription disposal should propagate"); + item3.HasSubscriptions.Should().BeFalse("subscription disposal should propagate"); + } + + protected abstract IObservable> BuildUut( + IObservable> source, + TimeSpan? changeSetBuffer = null, + TimeSpan? propertyChangeThrottle = null, + IScheduler? scheduler = null); + } +} diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs new file mode 100644 index 000000000..c948aedb8 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs @@ -0,0 +1,71 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshFixture +{ + public class WithPropertyAccessor + : Base + { + [Fact(Skip = "Existing defect: propertyAccessor is not null checked, throws NRE on first notification, instead")] + public void PropertyAccessorIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.AutoRefresh( + source: Observable.Never>(), + propertyAccessor: (null as Expression>)!)) + .Should() + .Throw(); + + [Fact] + public void PropertyChangedNotificationDoesNotMatchPropertyAccessor_IgnoresNotification() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + ++item2.OtherValue; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the property change notification should have been ignored"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + protected override IObservable> BuildUut( + IObservable> source, + TimeSpan? changeSetBuffer = null, + TimeSpan? propertyChangeThrottle = null, + IScheduler? scheduler = null) + => source.AutoRefresh( + propertyAccessor: static item => item.Value, + changeSetBuffer: changeSetBuffer, + propertyChangeThrottle: propertyChangeThrottle, + scheduler: scheduler); + } +} diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs new file mode 100644 index 000000000..402d175ff --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithoutPropertyAccessor.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Reactive.Concurrency; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshFixture +{ + public class WithoutPropertyAccessor + : Base + { + [Fact] + public void PropertyChangedNotificationDoesNotSpecifyPropertyName_ItemRefreshes() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var item1 = new Item() { Id = 1 }; + var item2 = new Item() { Id = 2 }; + var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut(source.Connect()) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + item2.RaiseAllPropertiesChanged(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a property change notification"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a property change notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + protected override IObservable> BuildUut( + IObservable> source, + TimeSpan? changeSetBuffer = null, + TimeSpan? propertyChangeThrottle = null, + IScheduler? scheduler = null) + => source.AutoRefresh( + changeSetBuffer: changeSetBuffer, + propertyChangeThrottle: propertyChangeThrottle, + scheduler: scheduler); + } +} diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs index b3d36aa41..be5b936a3 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.cs @@ -1,127 +1,66 @@ -using System; -using System.Linq; -using System.Reactive.Linq; - -using DynamicData.Binding; -using DynamicData.Tests.Domain; - -using FluentAssertions; - -using Xunit; +using System.ComponentModel; namespace DynamicData.Tests.Cache; -public class AutoRefreshFixture +public static partial class AutoRefreshFixture { - [Fact] - public void AutoRefresh() + public enum NotificationStrategy { - var items = Enumerable.Range(1, 100).Select(i => new Person("Person" + i, 1)).ToArray(); - - //result should only be true when all items are set to true - using var cache = new SourceCache(m => m.Name); - using var results = cache.Connect().AutoRefresh(p => p.Age).AsAggregator(); - cache.AddOrUpdate(items); - - results.Data.Count.Should().Be(100); - results.Messages.Count.Should().Be(1); - - items[0].Age = 10; - results.Data.Count.Should().Be(100); - results.Messages.Count.Should().Be(2); - - results.Messages[1].First().Reason.Should().Be(ChangeReason.Refresh); - - //remove an item and check no change is fired - var toRemove = items[1]; - cache.Remove(toRemove); - results.Data.Count.Should().Be(99); - results.Messages.Count.Should().Be(3); - toRemove.Age = 100; - results.Messages.Count.Should().Be(3); - - //add it back in and check it updates - cache.AddOrUpdate(toRemove); - results.Messages.Count.Should().Be(4); - toRemove.Age = 101; - results.Messages.Count.Should().Be(5); - - results.Messages.Last().First().Reason.Should().Be(ChangeReason.Refresh); - } - - [Fact] - public void AutoRefreshFromObservable() - { - var items = Enumerable.Range(1, 100).Select(i => new Person("Person" + i, 1)).ToArray(); - - //result should only be true when all items are set to true - using var cache = new SourceCache(m => m.Name); - using var results = cache.Connect().AutoRefreshOnObservable(p => p.WhenAnyPropertyChanged()).AsAggregator(); - cache.AddOrUpdate(items); - - results.Data.Count.Should().Be(100); - results.Messages.Count.Should().Be(1); - - items[0].Age = 10; - results.Data.Count.Should().Be(100); - results.Messages.Count.Should().Be(2); - - results.Messages[1].First().Reason.Should().Be(ChangeReason.Refresh); - - //remove an item and check no change is fired - var toRemove = items[1]; - cache.Remove(toRemove); - results.Data.Count.Should().Be(99); - results.Messages.Count.Should().Be(3); - toRemove.Age = 100; - results.Messages.Count.Should().Be(3); - - //add it back in and check it updates - cache.AddOrUpdate(toRemove); - results.Messages.Count.Should().Be(4); - toRemove.Age = 101; - results.Messages.Count.Should().Be(5); - - results.Messages.Last().First().Reason.Should().Be(ChangeReason.Refresh); + Immediate, + Asynchronous } - [Fact] - public void MakeSelectMagicWorkWithObservable() + public class Item + : INotifyPropertyChanged { - var initialItem = new IntHolder(1, "Initial Description"); - - var sourceList = new SourceList(); - sourceList.Add(initialItem); - - var descriptionStream = sourceList.Connect().AutoRefresh(intHolder => intHolder!.Description).Transform(intHolder => intHolder!.Description, true).Do(x => { }) // <--- Add break point here to check the overload fixes it - .Bind(out var resultCollection); - - using (descriptionStream.Subscribe()) + public static int SelectId(Item item) + => item.Id; + + public required int Id { - var newDescription = "New Description"; - initialItem.Description = newDescription; - - newDescription.Should().Be(resultCollection[0]); - //Assert.AreEqual(newDescription, resultCollection[0]); + get => _id; + init => _id = value; } - } - - public class IntHolder(int value, string description) : AbstractNotifyPropertyChanged - { - public string _description_ = description; - - public int _value = value; - - public string Description + + public bool HasSubscriptions + => PropertyChanged is not null; + + public int OtherValue { - get => _description_; - set => SetAndRaise(ref _description_, value); + get => _otherValue; + set + { + if (_otherValue == value) + return; + + _otherValue = value; + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(OtherValue))); + } } - + public int Value { get => _value; - set => SetAndRaise(ref _value, value); + set + { + if (_value == value) + return; + + _value = value; + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void RaiseAllPropertiesChanged() + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); + + private readonly int _id; + + private int _otherValue; + private int _value; } } diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs new file mode 100644 index 000000000..a09e0ea9c --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -0,0 +1,777 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Reactive.Testing; + +using FluentAssertions; +using Xunit; + +using DynamicData.Tests.Utilities; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshOnObservableFixture +{ + public abstract class Base + { + [Fact] + public void ChangeSetBufferIsGiven_ReevaluatorNotificationsAreBufferedOnScheduler() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged, + changeSetBuffer: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish reevaluator notification) + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the reevaluator notification should have been buffered"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, within buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("the buffer window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, to buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "a buffer window expired"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a reevaluator notification"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a reevaluator notification"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a reevaluator notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish reevaluator notification) + ++item1.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the reevaluator notification should have been buffered"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, within buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the buffer window has not yet ended"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (publish additional reevaluator notification) + ++item3.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty("the reevaluator notification should have been buffered"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (advance time, to buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "a buffer window expired"); + results.RecordedChangeSets.Skip(2).First().Count.Should().Be(2, "2 items published a reevaluator notification"); + results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(2, "2 items published a reevaluator notification"); + results.RecordedChangeSets.Skip(2).First().Select(change => change.Current).Should().BeEquivalentTo(new[] { item1, item3 }, "items #2 and #3 published reevaluator notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (normal refresh) + source.Refresh(item2); + + // Normal refreshes should not be buffered + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "one source operation was performed"); + results.RecordedChangeSets.Skip(3).First().Count.Should().Be(1, "1 item was refreshed, within the source"); + results.RecordedChangeSets.Skip(3).First().Refreshes.Should().Be(1, "1 item was refreshed, within the source"); + results.RecordedChangeSets.Skip(3).First().First().Current.Should().Be(item2, "item #2 was refreshed, within the source"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ItemIsAdded_SubscribesToReevaluator() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + source.AddOrUpdate(new[] { item1, item2, item3 }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + item1.HasObservers.Should().BeTrue("the reevaluator should be invoked and subscribed to, for each added item"); + item2.HasObservers.Should().BeTrue("the reevaluator should be invoked and subscribed to, for each added item"); + item3.HasObservers.Should().BeTrue("the reevaluator should be invoked and subscribed to, for each added item"); + } + + [Fact] + public void ItemIsMoved_NotificationPropagates() + { + // Setup + using var source = new Subject>(); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + var items = new[] { item1, item2, item3 }; + + var initialChangeset = new ChangeSet() + { + new Change(reason: ChangeReason.Add, key: item1.Id, current: item1, index: 0), + new Change(reason: ChangeReason.Add, key: item2.Id, current: item2, index: 1), + new Change(reason: ChangeReason.Add, key: item3.Id, current: item3, index: 2) + }; + + // UUT Initialization + using var subscription = BuildUut( + source: source.Prepend(initialChangeset), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "3 items were added to the source"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + items, + options => options.WithStrictOrdering(), + "item indexes should propagate"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + source.OnNext(new ChangeSet() + { + new Change( + key: item3.Id, + current: item3, + currentIndex: 0, + previousIndex: 2) + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(items, "an item was moved within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + results.RecordedItemsSorted.Should().BeEquivalentTo( + new[] { item3, item1, item2 }, + options => options.WithStrictOrdering(), + "an item was moved within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ItemIsRefreshed_NotificationPropagates() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var reevaluatorInvocationCount = 0; + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: item => + { + ++reevaluatorInvocationCount; + return Item.ObserveValueChanged(item); + }) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + reevaluatorInvocationCount.Should().Be(3, "the reevaluator should be invoked and subscribed to, for each added item"); + + // UUT Action + source.Refresh(item2); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item was refreshed within the source"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item was refreshed within the source"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 was refreshed within the source"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items were changed, within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + reevaluatorInvocationCount.Should().Be(3, "the reevaluator should only be invoked for items being added to the collection."); + } + + [Fact] + public void ItemIsRemoved_UnsubscribesFromReevaluator() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + source.Remove(item2); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was removed from the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + item2.HasObservers.Should().BeFalse("removing an item should trigger unsubscription from its reevaluator"); + item1.HasObservers.Should().BeTrue("the item was not removed from the source"); + item3.HasObservers.Should().BeTrue("the item was not removed from the source"); + } + + [Fact] + public void ItemIsUpdated_ReInvokesReevaluator() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + using var item4 = new Item() { Id = 2 }; + source.AddOrUpdate(item4); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "one source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was replaced within the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + item2.HasObservers.Should().BeFalse("replacing an item should trigger unsubscription from its reevaluator"); + item4.HasObservers.Should().BeTrue("adding an item should invoke its reevaluator and subscribe to it"); + item1.HasObservers.Should().BeTrue("the item was not removed from the source"); + item3.HasObservers.Should().BeTrue("the item was not removed from the source"); + + + // UUT Action (updated item publishes reevaluator notification) + ++item4.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 item published a reevaluation notification"); + results.RecordedChangeSets.Skip(2).First().Count.Should().Be(1, "1 item published a reevaluation notification"); + results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(1, "1 item published a reevaluation notification"); + results.RecordedChangeSets.Skip(2).First().First().Current.Should().Be(item4, "item #4 published a reevaluation notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void ReevaluatorCompletesWhenNotOnlyItemInSource_CompletionWaitsForSourceCompletionAndOtherReevaluatorCompletions(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization & Action (initial completion) + if (notificationStrategy is NotificationStrategy.Immediate) + item2.Complete(); + + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + item2.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("not all notification sources have completed"); + + + // UUT Action (remaining reevaluator completions) + item1.Complete(); + item3.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (source completion) + source.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeTrue("all notification sources have completed"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void ReevaluatorCompletesWhenOnlyItemInSource_CompletionWaitsForSourceCompletion(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item = new Item() { Id = 1 }; + + source.AddOrUpdate(item); + + + // UUT Initialization & Action (reevaluator completion) + if (notificationStrategy is NotificationStrategy.Immediate) + item.Complete(); + + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + item.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "1 item was added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action (source completion) + source.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeTrue("all notification sources have completed"); + } + + [Fact] + public void ReevaluatorEmitsAsynchronously_ItemRefreshes() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + ++item2.Value; + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 item published a reevaluation notification"); + results.RecordedChangeSets.Skip(1).First().Count.Should().Be(1, "1 item published a reevaluation notification"); + results.RecordedChangeSets.Skip(1).First().Refreshes.Should().Be(1, "1 item published a reevaluation notification"); + results.RecordedChangeSets.Skip(1).First().First().Current.Should().Be(item2, "item #2 published a reevaluation notification"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact(Skip = "Existing defect, #1099")] + public void ReevaluatorEmitsImmediately_ItemDoesNotRefresh() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization & Action + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValue) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedChangeSets[0].Refreshes.Should().Be(0, "re-evaluation notifications should be ignored within the initial subscription frame"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Theory(Skip = "Existing defect. Docs say that ignoring reevaluator exceptions is intentional, but it shouldn't be. Basic RX philosophy is that exceptions should basically always propagate.")] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void ReevaluatorFails_ErrorPropagates(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var error = new Exception("Test"); + + + // UUT Initialization & Action + if (notificationStrategy is NotificationStrategy.Immediate) + item2.SetError(error); + + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + item2.SetError(error); + + results.Error.Should().Be(error, "upstream errors should propagate downstream"); + if (notificationStrategy is NotificationStrategy.Immediate) + results.RecordedChangeSets.Should().BeEmpty("an error occurred during processing of the initial changeset"); + else + { + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + } + } + + [Fact(Skip = "Existing defect. Docs say that ignoring reevaluator exceptions is intentional, but it shouldn't be. Basic RX philosophy is that exceptions should basically always propagate.")] + public void ReevaluatorThrows_ExceptionPropagates() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + var error = new Exception("Test"); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: _ => throw error) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Should().BeEmpty("no initial changesets were published"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + using var item = new Item() { Id = 1 }; + source.AddOrUpdate(item); + + results.Error.Should().Be(error, "upstream errors should propagate downstream"); + results.RecordedChangeSets.Should().BeEmpty("an error occurred during processing of the initial changeset"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void SourceCompletesWhenEmpty_CompletionPropagates(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + + // UUT Initialization & Action + if (notificationStrategy is NotificationStrategy.Immediate) + source.Complete(); + + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeTrue("all notification sources have completed"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void SourceCompletesWhenNotEmpty_CompletionWaitsForReevaluatorCompletions(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + + // UUT Initialization & Action (source completion) + if (notificationStrategy is NotificationStrategy.Immediate) + source.Complete(); + + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + source.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("not all notification sources have completed"); + + + // UUT Action (initial reevaluator completion) + item2.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeFalse("not all notification sources have completed"); + + + // UUT Action (remaining reevaluator completions) + item1.Complete(); + item3.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeTrue("all notification sources have completed"); + } + + [Theory] + [InlineData(NotificationStrategy.Immediate)] + [InlineData(NotificationStrategy.Asynchronous)] + public void SourceFails_ErrorPropagates(NotificationStrategy notificationStrategy) + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + source.AddOrUpdate(new[] { item1, item2, item3 }); + + var error = new Exception("Test"); + + + // UUT Initialization & Action + if (notificationStrategy is NotificationStrategy.Immediate) + source.SetError(error); + + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + if (notificationStrategy is NotificationStrategy.Asynchronous) + source.SetError(error); + + results.Error.Should().Be(error, "upstream errors should propagate downstream"); + if (notificationStrategy is NotificationStrategy.Immediate) + results.RecordedChangeSets.Should().BeEmpty("an error occurred before the initial changeset"); + else + { + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "3 items were added to the source"); + } + } + + [Fact] + public void SourceIsNull_ThrowsException() + => FluentActions.Invoking(() => BuildUut( + source: null!, + reevaluator: Item.ObserveValueChanged)) + .Should() + .Throw(); + + [Fact] + public void SubscriptionIsDisposed_SubscriptionDisposalPropagates() + { + // Setup + using var source = new Subject>(); + + using var item1 = new Item() { Id = 1 }; + using var item2 = new Item() { Id = 2 }; + using var item3 = new Item() { Id = 3 }; + + var initialChangeset = new ChangeSet() + { + new Change(reason: ChangeReason.Add, key: item1.Id, current: item1), + new Change(reason: ChangeReason.Add, key: item2.Id, current: item2), + new Change(reason: ChangeReason.Add, key: item3.Id, current: item3) + }; + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Prepend(initialChangeset), + reevaluator: Item.ObserveValueChanged) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Count.Should().Be(1, "the initial changeset should propagate"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1, item2, item3 }, "3 items were added to the source"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + + // UUT Action + subscription.Dispose(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations were performed"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + + source.HasObservers.Should().BeFalse("subscription disposal should propagate"); + item1.HasObservers.Should().BeFalse("subscription disposal should propagate"); + item2.HasObservers.Should().BeFalse("subscription disposal should propagate"); + item3.HasObservers.Should().BeFalse("subscription disposal should propagate"); + } + + protected abstract IObservable> BuildUut( + IObservable> source, + Func> reevaluator, + TimeSpan? changeSetBuffer = null, + IScheduler? scheduler = null); + } +} diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs new file mode 100644 index 000000000..4fa7ec9d7 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithKey.cs @@ -0,0 +1,34 @@ +using System; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using FluentAssertions; +using Xunit; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshOnObservableFixture +{ + public class WithKey + : Base + { + [Fact] + public void ReevaluatorIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.AutoRefreshOnObservable( + source: Observable.Never>(), + reevaluator: (null as Func>)!)) + .Should() + .Throw(); + + protected override IObservable> BuildUut( + IObservable> source, + Func> reevaluator, + TimeSpan? changeSetBuffer = null, + IScheduler? scheduler = null) + => source.AutoRefreshOnObservable( + reevaluator: (item, _) => reevaluator.Invoke(item), + changeSetBuffer: changeSetBuffer, + scheduler: scheduler); + } +} diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs new file mode 100644 index 000000000..3e96d6e96 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs @@ -0,0 +1,34 @@ +using System; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using FluentAssertions; +using Xunit; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshOnObservableFixture +{ + public class WithoutKey + : Base + { + [Fact(Skip = "Existing defect: reevaluator is not null checked, throws NRW on first notification, instead")] + public void ReevaluatorIsNull_ThrowsException() + => FluentActions.Invoking(() => ObservableCacheEx.AutoRefreshOnObservable( + source: Observable.Never>(), + reevaluator: (null as Func>)!)) + .Should() + .Throw(); + + protected override IObservable> BuildUut( + IObservable> source, + Func> reevaluator, + TimeSpan? changeSetBuffer = null, + IScheduler? scheduler = null) + => source.AutoRefreshOnObservable( + reevaluator: reevaluator, + changeSetBuffer: changeSetBuffer, + scheduler: scheduler); + } +} diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs new file mode 100644 index 000000000..4e6f974a2 --- /dev/null +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.cs @@ -0,0 +1,79 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; + +namespace DynamicData.Tests.Cache; + +public static partial class AutoRefreshOnObservableFixture +{ + public enum NotificationStrategy + { + Immediate, + Asynchronous + } + + public sealed class Item + : IDisposable + { + public static IObservable ObserveValue(Item item) + => Observable.Create(observer => + { + observer.OnNext(item._value); + return item._valueChanged.SubscribeSafe(observer); + }); + + public static IObservable ObserveValueChanged(Item item) + => item._valueChanged.Select(static _ => Unit.Default); + + public static int SelectId(Item item) + => item.Id; + + public Item() + => _valueChanged = new(); + + public required int Id + { + get => _id; + init => _id = value; + } + + public bool HasObservers + => _valueChanged.HasObservers; + + public int Value + { + get => _value; + set + { + if (_value == value) + return; + + _value = value; + _valueChanged.OnNext(value); + } + } + + public void Complete() + => _valueChanged.OnCompleted(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _hasDisposed, true)) + return; + + _valueChanged.OnCompleted(); + _valueChanged.Dispose(); + } + + public void SetError(Exception error) + => _valueChanged.OnError(error); + + private readonly int _id; + private readonly Subject _valueChanged; + + private bool _hasDisposed; + private int _value; + } +} From 03ced2d61884d7229f863a55709fbb0dd11be176 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sat, 6 Jun 2026 10:00:48 -0700 Subject: [PATCH 04/14] Split ObservableListEx.cs into per-family partial classes (#1096) * Break ObservableListEx.cs into per-family partial classes Splits the 2900-line ObservableListEx.cs into 17 smaller partial-class files grouped by operator family. Each method (and all of its overloads) lives in exactly one file. The class declaration is changed to partial; no code, comments, or XML documentation is added, removed, or otherwise modified. All 2218 tests pass. * Rename Pagination to Virtualise and alphabetize list partial members Renames ObservableListEx.Pagination.cs to ObservableListEx.Virtualise.cs for closer parity with the cache equivalent (ObservableCacheEx.VirtualiseAndPage.cs). Sorts members alphabetically within each new partial file; overloads of the same name preserve their original declaration order. * Split ObservableListEx.cs partials into one file per operator (overload set) Mirrors the same convention applied to ObservableCacheEx (PR #1095): 1. ONE FILE PER OPERATOR NAME (one overload set per file). The previous 17 family files are replaced with 63 per-operator partial files. 2. BARE ObservableListEx.cs FILE restored to carry the canonical class-level XML documentation. All partials carry the same canonical class summary ('Extensions for ObservableList.') so SA1601 is satisfied and there are no divergent per-file class docs. 3. PRIVATE HELPERS placed AFTER all public members within their containing file. The 5 private 'Combine' overloads (used by And, Except, Or, Xor) are placed at the bottom of And.cs (alphabetically first caller). The byte content of every method body is preserved (verified programmatically). * Extract Combine private helpers into their own file Per Jake's review feedback, the five Combine private helpers (shared by And, Or, Except, Xor) move from ObservableListEx.And.cs to a dedicated ObservableListEx.Combine.cs, matching the per-operator pattern established for the public surface. Audit confirms Combine is the only multi-caller private helper in ObservableListEx partials. Byte-preserving move with no functional change. Library builds clean on all target frameworks. (cherry picked from commit e6d4e44f5266961544e6ee531ddc60555d9d1786) --- .../List/ObservableListEx.Adapt.cs | 60 + .../List/ObservableListEx.AddKey.cs | 51 + src/DynamicData/List/ObservableListEx.And.cs | 97 + .../List/ObservableListEx.AsObservableList.cs | 64 + .../List/ObservableListEx.AutoRefresh.cs | 101 + ...bservableListEx.AutoRefreshOnObservable.cs | 63 + src/DynamicData/List/ObservableListEx.Bind.cs | 149 + .../List/ObservableListEx.BufferIf.cs | 92 + .../List/ObservableListEx.BufferInitial.cs | 50 + src/DynamicData/List/ObservableListEx.Cast.cs | 65 + .../List/ObservableListEx.CastToObject.cs | 35 + .../List/ObservableListEx.Clone.cs | 46 + .../List/ObservableListEx.Combine.cs | 84 + .../List/ObservableListEx.Convert.cs | 46 + .../List/ObservableListEx.DeferUntilLoaded.cs | 61 + .../List/ObservableListEx.DisposeMany.cs | 60 + .../List/ObservableListEx.DistinctValues.cs | 60 + .../List/ObservableListEx.Except.cs | 96 + .../List/ObservableListEx.ExpireAfter.cs | 60 + .../List/ObservableListEx.Filter.cs | 150 + .../ObservableListEx.FilterOnObservable.cs | 66 + .../List/ObservableListEx.FilterOnProperty.cs | 54 + .../ObservableListEx.FlattenBufferResult.cs | 40 + .../List/ObservableListEx.ForEachChange.cs | 58 + .../ObservableListEx.ForEachItemChange.cs | 51 + .../List/ObservableListEx.GroupOn.cs | 64 + .../List/ObservableListEx.GroupOnProperty.cs | 57 + ...istEx.GroupOnPropertyWithImmutableState.cs | 58 + ...bservableListEx.GroupWithImmutableState.cs | 56 + .../List/ObservableListEx.LimitSizeTo.cs | 62 + .../List/ObservableListEx.MergeChangeSets.cs | 217 ++ .../List/ObservableListEx.MergeMany.cs | 59 + .../ObservableListEx.MergeManyChangeSets.cs | 146 + .../List/ObservableListEx.NotEmpty.cs | 42 + .../List/ObservableListEx.OnItemAdded.cs | 58 + .../List/ObservableListEx.OnItemRefreshed.cs | 45 + .../List/ObservableListEx.OnItemRemoved.cs | 66 + src/DynamicData/List/ObservableListEx.Or.cs | 89 + src/DynamicData/List/ObservableListEx.Page.cs | 53 + .../List/ObservableListEx.PopulateInto.cs | 48 + .../List/ObservableListEx.QueryWhenChanged.cs | 73 + .../List/ObservableListEx.RefCount.cs | 45 + .../List/ObservableListEx.RemoveIndex.cs | 44 + .../List/ObservableListEx.Reverse.cs | 45 + .../List/ObservableListEx.SkipInitial.cs | 52 + src/DynamicData/List/ObservableListEx.Sort.cs | 87 + .../List/ObservableListEx.StartWithEmpty.cs | 37 + .../List/ObservableListEx.SubscribeMany.cs | 58 + .../List/ObservableListEx.SuppressRefresh.cs | 36 + .../List/ObservableListEx.Switch.cs | 68 + .../List/ObservableListEx.ToCollection.cs | 37 + .../ObservableListEx.ToObservableChangeSet.cs | 185 ++ .../ObservableListEx.ToSortedCollection.cs | 59 + src/DynamicData/List/ObservableListEx.Top.cs | 53 + .../List/ObservableListEx.Transform.cs | 113 + .../List/ObservableListEx.TransformAsync.cs | 120 + .../List/ObservableListEx.TransformMany.cs | 82 + .../List/ObservableListEx.Virtualise.cs | 52 + ...ObservableListEx.WhenAnyPropertyChanged.cs | 50 + .../ObservableListEx.WhenPropertyChanged.cs | 53 + .../List/ObservableListEx.WhenValueChanged.cs | 50 + .../List/ObservableListEx.WhereReasonsAre.cs | 61 + .../ObservableListEx.WhereReasonsAreNot.cs | 74 + src/DynamicData/List/ObservableListEx.Xor.cs | 89 + src/DynamicData/List/ObservableListEx.cs | 2907 +---------------- 65 files changed, 4504 insertions(+), 2905 deletions(-) create mode 100644 src/DynamicData/List/ObservableListEx.Adapt.cs create mode 100644 src/DynamicData/List/ObservableListEx.AddKey.cs create mode 100644 src/DynamicData/List/ObservableListEx.And.cs create mode 100644 src/DynamicData/List/ObservableListEx.AsObservableList.cs create mode 100644 src/DynamicData/List/ObservableListEx.AutoRefresh.cs create mode 100644 src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs create mode 100644 src/DynamicData/List/ObservableListEx.Bind.cs create mode 100644 src/DynamicData/List/ObservableListEx.BufferIf.cs create mode 100644 src/DynamicData/List/ObservableListEx.BufferInitial.cs create mode 100644 src/DynamicData/List/ObservableListEx.Cast.cs create mode 100644 src/DynamicData/List/ObservableListEx.CastToObject.cs create mode 100644 src/DynamicData/List/ObservableListEx.Clone.cs create mode 100644 src/DynamicData/List/ObservableListEx.Combine.cs create mode 100644 src/DynamicData/List/ObservableListEx.Convert.cs create mode 100644 src/DynamicData/List/ObservableListEx.DeferUntilLoaded.cs create mode 100644 src/DynamicData/List/ObservableListEx.DisposeMany.cs create mode 100644 src/DynamicData/List/ObservableListEx.DistinctValues.cs create mode 100644 src/DynamicData/List/ObservableListEx.Except.cs create mode 100644 src/DynamicData/List/ObservableListEx.ExpireAfter.cs create mode 100644 src/DynamicData/List/ObservableListEx.Filter.cs create mode 100644 src/DynamicData/List/ObservableListEx.FilterOnObservable.cs create mode 100644 src/DynamicData/List/ObservableListEx.FilterOnProperty.cs create mode 100644 src/DynamicData/List/ObservableListEx.FlattenBufferResult.cs create mode 100644 src/DynamicData/List/ObservableListEx.ForEachChange.cs create mode 100644 src/DynamicData/List/ObservableListEx.ForEachItemChange.cs create mode 100644 src/DynamicData/List/ObservableListEx.GroupOn.cs create mode 100644 src/DynamicData/List/ObservableListEx.GroupOnProperty.cs create mode 100644 src/DynamicData/List/ObservableListEx.GroupOnPropertyWithImmutableState.cs create mode 100644 src/DynamicData/List/ObservableListEx.GroupWithImmutableState.cs create mode 100644 src/DynamicData/List/ObservableListEx.LimitSizeTo.cs create mode 100644 src/DynamicData/List/ObservableListEx.MergeChangeSets.cs create mode 100644 src/DynamicData/List/ObservableListEx.MergeMany.cs create mode 100644 src/DynamicData/List/ObservableListEx.MergeManyChangeSets.cs create mode 100644 src/DynamicData/List/ObservableListEx.NotEmpty.cs create mode 100644 src/DynamicData/List/ObservableListEx.OnItemAdded.cs create mode 100644 src/DynamicData/List/ObservableListEx.OnItemRefreshed.cs create mode 100644 src/DynamicData/List/ObservableListEx.OnItemRemoved.cs create mode 100644 src/DynamicData/List/ObservableListEx.Or.cs create mode 100644 src/DynamicData/List/ObservableListEx.Page.cs create mode 100644 src/DynamicData/List/ObservableListEx.PopulateInto.cs create mode 100644 src/DynamicData/List/ObservableListEx.QueryWhenChanged.cs create mode 100644 src/DynamicData/List/ObservableListEx.RefCount.cs create mode 100644 src/DynamicData/List/ObservableListEx.RemoveIndex.cs create mode 100644 src/DynamicData/List/ObservableListEx.Reverse.cs create mode 100644 src/DynamicData/List/ObservableListEx.SkipInitial.cs create mode 100644 src/DynamicData/List/ObservableListEx.Sort.cs create mode 100644 src/DynamicData/List/ObservableListEx.StartWithEmpty.cs create mode 100644 src/DynamicData/List/ObservableListEx.SubscribeMany.cs create mode 100644 src/DynamicData/List/ObservableListEx.SuppressRefresh.cs create mode 100644 src/DynamicData/List/ObservableListEx.Switch.cs create mode 100644 src/DynamicData/List/ObservableListEx.ToCollection.cs create mode 100644 src/DynamicData/List/ObservableListEx.ToObservableChangeSet.cs create mode 100644 src/DynamicData/List/ObservableListEx.ToSortedCollection.cs create mode 100644 src/DynamicData/List/ObservableListEx.Top.cs create mode 100644 src/DynamicData/List/ObservableListEx.Transform.cs create mode 100644 src/DynamicData/List/ObservableListEx.TransformAsync.cs create mode 100644 src/DynamicData/List/ObservableListEx.TransformMany.cs create mode 100644 src/DynamicData/List/ObservableListEx.Virtualise.cs create mode 100644 src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs create mode 100644 src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs create mode 100644 src/DynamicData/List/ObservableListEx.WhenValueChanged.cs create mode 100644 src/DynamicData/List/ObservableListEx.WhereReasonsAre.cs create mode 100644 src/DynamicData/List/ObservableListEx.WhereReasonsAreNot.cs create mode 100644 src/DynamicData/List/ObservableListEx.Xor.cs diff --git a/src/DynamicData/List/ObservableListEx.Adapt.cs b/src/DynamicData/List/ObservableListEx.Adapt.cs new file mode 100644 index 000000000..942d943c7 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Adapt.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Injects a side effect into a changeset stream via an . + /// The adaptor's Adapt method is invoked for each changeset before it is forwarded downstream unchanged. + /// + /// The type of items in the list. + /// The source to observe and adapt. + /// The adaptor whose Adapt method is invoked for each changeset. + /// A list changeset stream identical to the source, with the adaptor side effect applied. + /// or is . + /// + /// + /// This is the primary extension point for custom UI binding adaptors (e.g., + /// delegates to this operator). If the adaptor throws, the exception propagates downstream as OnError. + /// + /// + /// + public static IObservable> Adapt(this IObservable> source, IChangeSetAdaptor adaptor) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); + + return Observable.Create>( + observer => + { + var locker = InternalEx.NewLock(); + return source.Synchronize(locker).Select( + changes => + { + adaptor.Adapt(changes); + return changes; + }).SubscribeSafe(observer); + }); + } +} diff --git a/src/DynamicData/List/ObservableListEx.AddKey.cs b/src/DynamicData/List/ObservableListEx.AddKey.cs new file mode 100644 index 000000000..0ead13a87 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.AddKey.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Adds a key to each item in a list changeset, converting it to a cache changeset that supports all keyed DynamicData operators. + /// + /// The type of items in the list. + /// The type of the key. + /// The source to add keys to, converting to a cache changeset. + /// A function to extract a unique key from each item. + /// A cache changeset stream with keyed items. + /// or is . + /// + /// + /// All index information is dropped during conversion because cache changesets are unordered by default. + /// Use this when you need to transition from list-based pipelines to cache-based operators (Filter by key, Join, Group, etc.). + /// + /// + /// + public static IObservable> AddKey(this IObservable> source, Func keySelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return source.Select(changes => new ChangeSet(new AddKeyEnumerator(changes, keySelector))); + } +} diff --git a/src/DynamicData/List/ObservableListEx.And.cs b/src/DynamicData/List/ObservableListEx.And.cs new file mode 100644 index 000000000..2dee36d90 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.And.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies a logical AND (intersection) between multiple list changeset streams. + /// Only items present in ALL sources appear in the result. + /// + /// The type of items in the lists. + /// The first source to intersect. + /// The additional changeset streams to intersect with. + /// A list changeset stream containing items that exist in every source. + /// is . + /// + /// + /// Uses reference counting per item across all sources. An item appears downstream only when + /// its reference count is non-zero in ALL sources. Item identity is determined by the default equality comparer. + /// + /// + /// EventBehavior + /// Add/AddRangeThe item's reference count is incremented in its source tracker. If the item is now present in all sources, an Add is emitted. + /// ReplaceThe old item's reference count is decremented and the new item's is incremented. Depending on whether each is present in ALL sources, this emits an Add, Remove, Replace, or nothing. + /// Remove/RemoveRange/ClearThe item's reference count is decremented. If it was in the result and is no longer in all sources, a Remove is emitted. + /// RefreshForwarded as Refresh if the item is currently in the result. + /// MovedIgnored (set operations are position-independent). + /// + /// Worth noting: Item identity uses object equality, not position. Duplicate items in a single source are reference-counted independently. + /// + /// + /// + /// + /// + public static IObservable> And(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.And, others); + } + + /// + /// A of changeset streams to intersect. + /// + /// + /// This overload accepts a pre-built collection of sources instead of a params array. + /// + public static IObservable> And(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// An of changeset streams. Sources can be added or removed dynamically. + /// + /// + /// This overload supports dynamic source management: adding or removing changeset streams from the observable list triggers re-evaluation. + /// + public static IObservable> And(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// An of . Each inner list's changes are connected automatically. + /// + /// + /// This overload accepts instances directly, calling Connect() internally. + /// + public static IObservable> And(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// An of . Each inner list's changes are connected automatically. + /// + /// + /// This overload accepts instances directly, calling Connect() internally. + /// + public static IObservable> And(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.And); +} diff --git a/src/DynamicData/List/ObservableListEx.AsObservableList.cs b/src/DynamicData/List/ObservableListEx.AsObservableList.cs new file mode 100644 index 000000000..478ae5b63 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.AsObservableList.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Wraps a as a read-only , hiding mutation methods. + /// + /// The type of items in the list. + /// The mutable source list to wrap. + /// A read-only observable list that mirrors the source. + /// is . + public static IObservableList AsObservableList(this ISourceList source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new AnonymousObservableList(source); + } + + /// + /// Materializes a changeset stream into a read-only . + /// The list is kept in sync with the source stream for the lifetime of the subscription. + /// + /// The type of items in the list. + /// The source to materialize into a read-only list. + /// A read-only observable list reflecting the current state of the stream. + /// is . + /// + /// + /// This is the primary way to multicast a changeset pipeline. Materializing once into an , + /// then calling Connect() on the result for each downstream consumer, ensures the upstream operators are evaluated only once + /// regardless of how many subscribers consume the result. + /// + /// + /// + public static IObservableList AsObservableList(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new AnonymousObservableList(source); + } +} diff --git a/src/DynamicData/List/ObservableListEx.AutoRefresh.cs b/src/DynamicData/List/ObservableListEx.AutoRefresh.cs new file mode 100644 index 000000000..d5e0660f8 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.AutoRefresh.cs @@ -0,0 +1,101 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Monitors all properties on each item (via ) and emits Refresh + /// changes when any property changes, causing downstream operators to re-evaluate. + /// + /// The type of items, which must implement . + /// The source to monitor for property-driven refresh signals. + /// An optional buffer duration to batch multiple refresh signals into a single changeset. + /// An optional throttle applied to each item's property change notifications. + /// The scheduler for throttle and buffer timing. Defaults to . + /// A list changeset stream with additional Refresh changes injected when properties change. + /// is . + /// + /// + /// Wraps using WhenAnyPropertyChanged() as the re-evaluator. + /// Pair with or + /// to get reactive re-evaluation on property changes. + /// + /// + /// EventBehavior + /// Add/AddRangeSubscribes to PropertyChanged on each new item. The original change is forwarded. + /// ReplaceUnsubscribes from the old item, subscribes to the new. The original change is forwarded. + /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. + /// Moved/RefreshForwarded unchanged. + /// Property changesA Refresh change is emitted for the item whose property changed. + /// + /// Worth noting: Each item generates a subscription. For large lists with frequent property changes, use and to reduce churn. + /// + /// + /// + /// + /// + public static IObservable> AutoRefresh(this IObservable> source, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.AutoRefreshOnObservable( + t => + { + if (propertyChangeThrottle is null) + { + return t.WhenAnyPropertyChanged(); + } + + return t.WhenAnyPropertyChanged().Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); + }, + changeSetBuffer, + scheduler); + } + + /// + /// Monitors a single property (selected by ) on each item via + /// and emits Refresh changes when that property changes, causing downstream operators to re-evaluate. More efficient than + /// the all-properties overload when only one property (of type ) affects downstream behavior. + /// + /// + public static IObservable> AutoRefresh(this IObservable> source, Expression> propertyAccessor, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + return source.AutoRefreshOnObservable( + t => + { + if (propertyChangeThrottle is null) + { + return t.WhenPropertyChanged(propertyAccessor, false); + } + + return t.WhenPropertyChanged(propertyAccessor, false).Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); + }, + changeSetBuffer, + scheduler); + } +} diff --git a/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs new file mode 100644 index 000000000..dc3ede92c --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Monitors each item with a custom observable and emits Refresh changes whenever that observable fires, + /// causing downstream operators (Filter, Sort, Group) to re-evaluate. + /// + /// The type of items in the list. + /// The type emitted by the re-evaluator observable (value is ignored). + /// The source to monitor for observable-driven refresh signals. + /// A factory that, given an item, returns an observable whose emissions trigger a Refresh for that item. + /// An optional buffer duration to batch refresh signals into a single changeset. + /// The for buffering. + /// A list changeset stream with additional Refresh changes injected when per-item observables fire. + /// or is . + /// + /// + /// This is the general-purpose refresh mechanism. + /// is a convenience wrapper that uses WhenAnyPropertyChanged() as the re-evaluator. + /// + /// + /// EventBehavior + /// Add/AddRangeSubscribes to the re-evaluator observable for each new item. The original change is forwarded. + /// ReplaceUnsubscribes from the old item's observable, subscribes to the new. The original change is forwarded. + /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. + /// Moved/RefreshForwarded unchanged. + /// Re-evaluator firesThe item's current index is looked up and a Refresh change is emitted. + /// + /// + /// + /// + /// + public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); + + return new AutoRefresh(source, reevaluator, changeSetBuffer, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Bind.cs b/src/DynamicData/List/ObservableListEx.Bind.cs new file mode 100644 index 000000000..fe14bece5 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Bind.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies changeset mutations to a target for UI data binding. + /// + /// The type of items in the list. + /// The source to bind to a collection. + /// The target collection to keep in sync. + /// When a changeset exceeds this many changes, the collection is reset instead of applying individual changes. + /// A continuation of the source changeset stream (allows further chaining). + /// or is . + /// + /// + /// Delegates to with an internal collection adaptor. + /// Each changeset is applied to the target collection on the calling thread. For UI binding, ensure the source is + /// observed on the UI thread (e.g., via ObserveOn). + /// + /// + /// EventBehavior + /// AddItem inserted at the specified index in the target collection. + /// AddRangeItems inserted as a range. If the count exceeds , the collection is cleared and repopulated. + /// ReplaceItem at the specified index is replaced. + /// RemoveItem at the specified index is removed. + /// RemoveRange/ClearItems removed from the collection. + /// MovedItem is moved between positions in the collection. + /// RefreshDepends on the adaptor implementation. + /// + /// + /// + /// + /// + /// + /// + public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + + var options = resetThreshold == BindingOptions.DefaultResetThreshold + ? defaults + : defaults with { ResetThreshold = resetThreshold }; + + return source.Bind(targetCollection, options); + } + + /// + /// Binds the source changeset stream to , with fine-grained control over reset threshold and other behaviors. + /// + /// + public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, BindingOptions options) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); + + var adaptor = new ObservableCollectionAdaptor(targetCollection, options); + return source.Adapt(adaptor); + } + + /// + /// Constructs a and binds the changeset stream to it. + /// Use this overload when you need a read-only view (typically for UI binding) without managing the backing collection yourself. + /// The created collection is returned via the output parameter. + /// + /// + /// + /// + /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. + /// + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + var options = resetThreshold == BindingOptions.DefaultResetThreshold + ? defaults + : defaults with { ResetThreshold = resetThreshold }; + + return source.Bind(out readOnlyObservableCollection, options); + } + + /// + /// Constructs a and binds the changeset stream to it, + /// with fine-grained control over reset threshold and other behaviors. + /// The created collection is returned via the output parameter. + /// + /// + /// + /// + /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. + /// + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + var target = new ObservableCollectionExtended(); + var result = new ReadOnlyObservableCollection(target); + var adaptor = new ObservableCollectionAdaptor(target, options); + readOnlyObservableCollection = result; + return source.Adapt(adaptor); + } + +#if SUPPORTS_BINDINGLIST + /// + /// Binds the source changeset stream to a WinForms , keeping in sync. + /// + /// + public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); + + return source.Adapt(new BindingListAdaptor(bindingList, resetThreshold)); + } +#endif +} diff --git a/src/DynamicData/List/ObservableListEx.BufferIf.cs b/src/DynamicData/List/ObservableListEx.BufferIf.cs new file mode 100644 index 000000000..37224290f --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.BufferIf.cs @@ -0,0 +1,92 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// + /// + /// This overload starts unpaused and has no timeout. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, IScheduler? scheduler = null) + where T : notnull => BufferIf(source, pauseIfTrueSelector, false, scheduler); + + /// + /// + /// + /// This overload allows setting the initial pause state but has no timeout. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, IScheduler? scheduler = null) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); + + return BufferIf(source, pauseIfTrueSelector, initialPauseState, null, scheduler); + } + + /// + /// + /// + /// This overload starts unpaused and accepts a timeout but not an explicit initial pause state. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, TimeSpan? timeOut, IScheduler? scheduler = null) + where T : notnull => BufferIf(source, pauseIfTrueSelector, false, timeOut, scheduler); + + /// + /// Buffers changeset notifications while a pause signal is active, then flushes all buffered changes when resumed. + /// + /// The type of items in the list. + /// The source to conditionally buffer. + /// An of that controls buffering: pauses (buffers), resumes (flushes). + /// The initial pause state. When , buffering starts immediately. + /// An optional maximum duration to keep the buffer open. After this time, the buffer is flushed regardless of pause state. + /// The for timeout scheduling. + /// A list changeset stream that buffers during pause and emits combined changesets on resume. + /// or is . + /// + /// + /// All changeset events are buffered at the changeset level (not individual changes) while paused. + /// On resume, all buffered changesets are emitted as a single combined changeset. If the buffer is empty on resume, + /// no emission occurs. + /// + /// + /// EventBehavior + /// Any (while paused)Accumulated in an internal buffer. Not emitted downstream. + /// Any (while active)Passed through immediately. + /// Pause selector emits falseAll buffered changesets are flushed downstream as one combined changeset. + /// Timeout firesAutomatically resumes and flushes the buffer. + /// OnErrorForwarded immediately (not buffered). + /// OnCompletedForwarded immediately. + /// + /// Worth noting: Each pause/resume cycle re-arms the timeout. Rapid toggling can create many small buffer windows. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, TimeSpan? timeOut, IScheduler? scheduler = null) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); + + return new BufferIf(source, pauseIfTrueSelector, initialPauseState, timeOut, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.BufferInitial.cs b/src/DynamicData/List/ObservableListEx.BufferInitial.cs new file mode 100644 index 000000000..17ad6d43b --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.BufferInitial.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Buffers changesets during an initial time window, then emits a single combined changeset and passes through subsequent changes. + /// + /// The type of items in the list. + /// The source to buffer during the initial loading period. + /// The time period (measured from first emission) during which changes are buffered. + /// The for timing the buffer window. + /// A list changeset stream where the initial burst is combined into one changeset. + /// + /// + /// For a configured duration after the first emission, all changesets are buffered and combined into a single emission. + /// After this initial window, subsequent changesets pass through immediately. + /// + /// + /// + /// + public static IObservable> BufferInitial(this IObservable> source, TimeSpan initialBuffer, IScheduler? scheduler = null) + where TObject : notnull => source.DeferUntilLoaded().Publish( + shared => + { + var initial = shared.Buffer(initialBuffer, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult().Take(1); + + return initial.Concat(shared); + }); +} diff --git a/src/DynamicData/List/ObservableListEx.Cast.cs b/src/DynamicData/List/ObservableListEx.Cast.cs new file mode 100644 index 000000000..7c0f9aad7 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Cast.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Casts each item in the changeset from object to using a direct cast. + /// + /// The target type to cast to. + /// The source of object items. + /// A list changeset stream of cast items. + /// is . + /// + /// + public static IObservable> Cast(this IObservable> source) + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select(changes => changes.Transform(t => (TDestination)t)); + } + + /// + /// Transforms each item in the changeset using a conversion function. + /// + /// The source item type. + /// The destination item type. + /// The source to cast. + /// A function to convert each item from to . + /// A list changeset stream of converted items. + /// or is . + /// Use this overload when type inference requires explicit specification of both source and destination types. Alternatively, call first, then the single-type-parameter overload. + /// + /// + public static IObservable> Cast(this IObservable> source, Func conversionFactory) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); + + return source.Select(changes => changes.Transform(conversionFactory)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.CastToObject.cs b/src/DynamicData/List/ObservableListEx.CastToObject.cs new file mode 100644 index 000000000..4695297da --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.CastToObject.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Casts each item in the changeset to object. Typically used before to work around type inference limitations. + /// + /// The source item type (must be a reference type). + /// The source to cast to object. + /// A list changeset stream of object items. + /// + public static IObservable> CastToObject(this IObservable> source) + where T : class => source.Select(changes => changes.Transform(t => (object)t)); +} diff --git a/src/DynamicData/List/ObservableListEx.Clone.cs b/src/DynamicData/List/ObservableListEx.Clone.cs new file mode 100644 index 000000000..2588c8927 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Clone.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies each changeset to the target list as a side effect, keeping it synchronized with the source. + /// + /// The type of items in the list. + /// The source to clone. + /// The target list to clone changes into. + /// A continuation of the source changeset stream. + /// is . + /// + /// Lower-level than . Uses .Clone() to apply all changeset operations directly. + /// + /// + /// + public static IObservable> Clone(this IObservable> source, IList target) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Do(target.Clone); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Combine.cs b/src/DynamicData/List/ObservableListEx.Combine.cs new file mode 100644 index 000000000..13a617439 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Combine.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + private static IObservable> Combine(this ICollection>> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new Combiner(sources, type).Run(); + } + + private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] others) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + if (others.Length == 0) + { + throw new ArgumentException("Must be at least one item to combine with", nameof(others)); + } + + var items = source.EnumerateOne().Union(others).ToList(); + return new Combiner(items, type).Run(); + } + + private static IObservable> Combine(this IObservableList> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); + var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(changesSetList, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); + var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(changesSetList, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList>> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new DynamicCombiner(sources, type).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Convert.cs b/src/DynamicData/List/ObservableListEx.Convert.cs new file mode 100644 index 000000000..4380c296d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Convert.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Convert the object using the specified conversion function. + /// This is a lighter equivalent of Transform and is designed to be used with non-disposable objects. + /// + /// The type of items in the list. + /// The type of the destination items. + /// The source to convert. + /// The conversion factory. + /// An observable which emits the change set. + [Obsolete("Prefer Cast as it is does the same thing but is semantically correct")] + public static IObservable> Convert(this IObservable> source, Func conversionFactory) + where TObject : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); + + return source.Select(changes => changes.Transform(conversionFactory)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.DeferUntilLoaded.cs b/src/DynamicData/List/ObservableListEx.DeferUntilLoaded.cs new file mode 100644 index 000000000..1e6b5d037 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.DeferUntilLoaded.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Defers downstream delivery until the source emits its first changeset, then forwards all subsequent changesets. + /// + /// The type of the object. + /// The source to defer until the first changeset arrives. + /// A list changeset stream that begins emitting only after the source has produced its first changeset. + /// is . + /// + /// + /// Subscribes to the source immediately but buffers internally until the first changeset arrives, at which point it emits + /// the initial data and all subsequent changesets. This is useful when downstream consumers should not receive an empty initial state. + /// + /// + /// + /// + public static IObservable> DeferUntilLoaded(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DeferUntilLoaded(source).Run(); + } + + /// + /// + /// + /// Convenience overload that calls source.Connect().DeferUntilLoaded(). + /// + public static IObservable> DeferUntilLoaded(this IObservableList source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().DeferUntilLoaded(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.DisposeMany.cs b/src/DynamicData/List/ObservableListEx.DisposeMany.cs new file mode 100644 index 000000000..ec463f21d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.DisposeMany.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Disposes items that implement when they are removed, replaced, or cleared from the stream. + /// All remaining tracked items are disposed when the stream finalizes (OnCompleted, OnError, or subscription disposal). + /// + /// The type of the object. + /// The source to track for disposal on removal. + /// A continuation of the source changeset stream with disposal side effects applied. + /// is . + /// + /// + /// Items are cast to and disposed after the changeset has been forwarded downstream. + /// Items that do not implement are silently ignored. + /// + /// + /// EventBehavior + /// Add/AddRangeItems are tracked for future disposal. Changeset forwarded. + /// ReplaceThe previous (replaced) item is disposed after the changeset is forwarded. The new item is tracked. + /// Remove/RemoveRangeRemoved items are disposed after the changeset is forwarded. + /// ClearAll tracked items are disposed after the changeset is forwarded. + /// Moved/RefreshForwarded. No disposal occurs. + /// OnError/OnCompleted/DisposalAll remaining tracked items are disposed during finalization. + /// + /// Worth noting: Disposal happens after the changeset is delivered downstream, so subscribers see the change before items are disposed. + /// + /// + /// + /// + public static IObservable> DisposeMany(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DisposeMany(source).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.DistinctValues.cs b/src/DynamicData/List/ObservableListEx.DistinctValues.cs new file mode 100644 index 000000000..39181d6c8 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.DistinctValues.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Extracts distinct values from source items using , with reference counting to track when values enter and leave the result set. + /// + /// The type of items in the source list. + /// The type of distinct values produced. + /// The source to extract distinct values. + /// A function that extracts the value to track from each source item. + /// A list changeset stream of distinct values. + /// or is . + /// + /// + /// Maintains an internal reference count per distinct value. A value is included when its count first exceeds zero + /// and removed when its count drops back to zero. + /// + /// + /// EventBehavior + /// Add/AddRangeValue extracted. If first occurrence, an Add is emitted. Otherwise the reference count is incremented silently. + /// ReplaceOld value's reference count decremented (removed if zero), new value's count incremented (added if first). If the value did not change, no emission. + /// Remove/RemoveRangeReference count decremented. If the count reaches zero, a Remove is emitted for that distinct value. + /// RefreshValue is re-extracted. If changed, old value decremented and new value incremented (same as Replace logic). + /// ClearAll reference counts cleared. Remove emitted for every tracked distinct value. + /// + /// + /// + public static IObservable> DistinctValues(this IObservable> source, Func valueSelector) + where TObject : notnull + where TValue : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + valueSelector.ThrowArgumentNullExceptionIfNull(nameof(valueSelector)); + + return new Distinct(source, valueSelector).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Except.cs b/src/DynamicData/List/ObservableListEx.Except.cs new file mode 100644 index 000000000..7c19912d8 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Except.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies a logical set-difference (Except) between the source and other streams. + /// Items present in the first source but not in any of the are included in the result. + /// + /// The type of the item. + /// The primary from which other streams are subtracted. + /// The other changeset streams to exclude from the result. + /// A list changeset stream containing items from that are not in any of . + /// is . + /// + /// + /// Item identity is determined by the default equality comparer for . Across all sources, items are tracked + /// by reference-counted equality (not by index position). + /// The first source has a special role: only items from it can appear in the result, and only if they do not exist in any other source. + /// + /// + /// EventBehavior + /// Add/AddRange (first source)If the item does not exist in any other source, an Add is emitted. + /// Add/AddRange (other source)If the item was in the result (from first source), a Remove is emitted. + /// Remove/RemoveRange/Clear (first source)If the item was in the result, a Remove is emitted. + /// Remove/RemoveRange/Clear (other source)If the item exists in the first source and no longer in any other, an Add is emitted. + /// ReplaceTreated as a Remove of the old item plus an Add of the new item, with set logic re-evaluated. + /// MovedIgnored by the set logic (no positional semantics). + /// RefreshForwarded if the item is currently in the result set. + /// + /// Worth noting: Unlike , the first source is asymmetric: only its items can appear in the result. + /// + /// + /// + /// + /// + public static IObservable> Except(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.Except, others); + } + + /// + /// + /// + /// Static overload accepting a pre-built collection of sources. The first item in the collection is the primary source. + /// + public static IObservable> Except(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// + /// Dynamic overload: sources can be added or removed from the at runtime. The first source in the list acts as the primary. + /// + public static IObservable> Except(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// + /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Except(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// + /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Except(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Except); +} diff --git a/src/DynamicData/List/ObservableListEx.ExpireAfter.cs b/src/DynamicData/List/ObservableListEx.ExpireAfter.cs new file mode 100644 index 000000000..4df348372 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ExpireAfter.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Automatically removes items from the list after the duration returned by . + /// Returns an observable of the items that were expired and removed. + /// + /// The type of the item. + /// The source list to apply time-based expiration to. + /// A function returning the time-to-live for each item. Return for items that should never expire. + /// An optional polling interval to batch expiry checks. If omitted, a separate timer is created for each unique expiry time. + /// The scheduler for scheduling expiry timers. Defaults to . + /// An observable that emits collections of items each time expired items are removed from the source list. + /// + /// + /// This operator acts directly on an , not on a changeset stream. It monitors items as they are added, + /// schedules their removal, and physically removes them from the source list when their time expires. + /// + /// + /// When is specified, all items due for removal are batched into a single removal at each polling tick, + /// which can improve performance when many items expire around the same time. + /// + /// Worth noting: The returned observable emits the expired items (not changesets). Subscribe to this observable to trigger the expiry mechanism; if not subscribed, no items will be removed. + /// + /// + /// + public static IObservable> ExpireAfter( + this ISourceList source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ExpireAfter.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); +} diff --git a/src/DynamicData/List/ObservableListEx.Filter.cs b/src/DynamicData/List/ObservableListEx.Filter.cs new file mode 100644 index 000000000..2165375b0 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Filter.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Filters items from the source list changeset stream using a static predicate. + /// Only items satisfying are included downstream. + /// + /// The type of items in the list. + /// The source to filter. + /// A predicate that determines which items are included. Items returning appear downstream; items returning are excluded. + /// A list changeset stream containing only items that satisfy . + /// Thrown when or is . + /// + /// + /// Use this overload when you need only a single predicate function for the lifetime of the subscription; + /// unlike the dynamic-predicate and state-driven overloads, the predicate function itself never changes. + /// Note that this does not mean an item's inclusion is fixed: Refresh events can re-evaluate each item against the predicate + /// and promote a previously-excluded item to included (or vice versa). + /// Item ordering is preserved. + /// + /// + /// EventBehavior + /// AddThe predicate is evaluated. If the item passes, an Add is emitted at the calculated downstream index. Otherwise dropped. + /// AddRangeEach item in the range is evaluated. Matching items are emitted as an AddRange. + /// ReplaceThe predicate is re-evaluated. Four outcomes: both pass produces Replace; new passes but old didn't produces Add; old passed but new doesn't produces Remove; neither passes is dropped. + /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. + /// RemoveRangeIncluded items in the range are emitted as individual Remove changes. + /// RefreshThe predicate is re-evaluated. If the item now passes but previously did not, an Add is emitted. If it previously passed but no longer does, a Remove is emitted. If still passes, the Refresh is forwarded. If still fails, dropped. + /// ClearAll downstream items are cleared. + /// + /// Worth noting: Refresh events trigger re-evaluation, which can promote or demote items (turning a Refresh into an Add or Remove). Pair with for property-change-driven filtering. + /// + /// + /// + /// + /// + public static IObservable> Filter( + this IObservable> source, + Func predicate) + where T : notnull + => List.Internal.Filter.Static.Create( + source: source, + predicate: predicate, + suppressEmptyChangesets: true); + + /// + /// Filters items using a dynamically changing predicate. + /// When emits a new function, all items are re-evaluated. + /// + /// The type of the item. + /// The source to filter. + /// An that emits new predicate functions. Each emission triggers a full re-evaluation of all items. + /// The that controls re-filtering behavior when the predicate changes. + /// A list changeset stream containing only items that satisfy the most recent predicate. + /// + /// + /// Each time emits, every item is re-evaluated against the new predicate. + /// + /// + /// EventBehavior + /// AddThe current predicate is evaluated. If the item passes, an Add is emitted. Otherwise dropped. + /// AddRangeEach item is evaluated. Matching items are emitted as AddRange. + /// ReplaceRe-evaluated. Same four-outcome logic as the static overload (Replace, Add, Remove, or dropped). + /// RemoveIf the item was downstream, a Remove is emitted. Otherwise dropped. + /// RefreshRe-evaluated. If inclusion status changed, an Add or Remove is emitted. If unchanged, Refresh forwarded or dropped. + /// ClearAll downstream items are cleared. + /// Predicate changedAll items are re-evaluated against the new predicate. The output is shaped by . + /// OnCompletedIndependent completion of does not terminate the filter. + /// + /// Worth noting: No items are included until emits its first function. + /// + /// or is . + /// + /// + public static IObservable> Filter(this IObservable> source, IObservable> predicate, ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); + + return new List.Internal.Filter.Dynamic(source, predicate, filterPolicy).Run(); + } + + /// + /// Filters items using a predicate that receives external state. When emits a new state value, + /// all items are re-evaluated against using the updated state. + /// + /// The type of the item. + /// The type of state value required by . + /// The source to filter. + /// An stream of state values to be passed to . + /// A static predicate receiving the current state and an item, returning to include or to exclude. The function itself does not change; only the state value passed to it changes. + /// The that controls re-filtering behavior when the state changes. + /// When (default), empty changesets are suppressed. Set to to publish empty changesets (useful for monitoring loading status). + /// A list changeset stream containing only items satisfying with the current state. + /// , , or is . + /// + /// + /// The predicate cannot be invoked until the first state value is received. Until then, all items are treated as excluded. + /// Each subsequent state emission triggers a full re-evaluation of all items according to . + /// + /// + /// EventBehavior + /// Add/AddRangeEvaluated using current state. Matching items emitted as Add/AddRange. + /// ReplaceRe-evaluated. Same four-outcome logic as the static filter (Replace, Add, Remove, or dropped). + /// Remove/RemoveRangeIf the item was downstream, a Remove is emitted. + /// RefreshRe-evaluated against current state. Inclusion status may change. + /// ClearAll downstream items are cleared. + /// State changedAll items are re-evaluated with the new state value. The output is shaped by . + /// + /// + /// + /// + public static IObservable> Filter( + this IObservable> source, + IObservable predicateState, + Func predicate, + ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff, + bool suppressEmptyChangeSets = true) + where T : notnull + => List.Internal.Filter.WithPredicateState.Create( + source: source, + predicateState: predicateState, + predicate: predicate, + filterPolicy: filterPolicy, + suppressEmptyChangeSets: suppressEmptyChangeSets); +} diff --git a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs new file mode 100644 index 000000000..bd49bf1db --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Filters each item using a per-item of that dynamically controls inclusion. + /// When an item's observable emits the item enters the result; when it emits the item is removed. + /// + /// The type of items in the list. + /// The source to filter by property value. + /// A function that returns an observable of for each item, controlling its inclusion. + /// An optional throttle duration applied to each per-item observable to reduce re-evaluation frequency. + /// The used when throttling. Defaults to the system default scheduler. + /// A list changeset stream containing only items whose per-item observable most recently emitted . + /// or is . + /// + /// + /// Each item in the source gets its own subscription to the observable returned by . + /// The item's inclusion is determined by the most recent boolean value emitted by that observable. + /// + /// + /// Event (source)Behavior + /// Add/AddRangeSubscribes to the per-item observable. Item is included when it first emits . + /// ReplaceOld subscription disposed, new subscription created for the replacement item. + /// Remove/RemoveRange/ClearSubscription disposed. If the item was downstream, a Remove is emitted. + /// RefreshForwarded if the item is currently included. + /// + /// + /// Event (per-item observable)Behavior + /// Emits If not already included, an Add is emitted downstream. + /// Emits If currently included, a Remove is emitted downstream. + /// + /// + /// + /// + /// + /// + public static IObservable> FilterOnObservable(this IObservable> source, Func> objectFilterObservable, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new FilterOnObservable(source, objectFilterObservable, propertyChangedThrottle, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.FilterOnProperty.cs b/src/DynamicData/List/ObservableListEx.FilterOnProperty.cs new file mode 100644 index 000000000..b9b297d77 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.FilterOnProperty.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Filters items based on a property value, automatically re-evaluating when the specified property changes on any item. + /// + /// The type of the object. Must implement . + /// The type of the property. + /// The source to filter by property value. + /// selecting the property to monitor for changes. + /// A predicate evaluated against the item to determine inclusion. + /// An optional throttle duration for property change notifications. + /// The used when throttling. + /// A list changeset stream of items satisfying the predicate, re-evaluated on property changes. + /// + /// Deprecated. Use followed by instead. + /// + /// + /// + [Obsolete("Use AutoRefresh(), followed by Filter() instead")] + public static IObservable> FilterOnProperty(this IObservable> source, Expression> propertySelector, Func predicate, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); + + return new FilterOnProperty(source, propertySelector, predicate, propertyChangedThrottle, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.FlattenBufferResult.cs b/src/DynamicData/List/ObservableListEx.FlattenBufferResult.cs new file mode 100644 index 000000000..667a494ad --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.FlattenBufferResult.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Flattens buffered changesets (e.g. from ) back into single changesets. + /// Empty buffers are dropped. + /// + /// The type of the item. + /// The of buffered changeset lists. + /// A list changeset stream with all buffered changes concatenated into single changesets. + /// + /// Use this after applying Observable.Buffer() to a changeset stream to re-merge the batched changesets into a single stream. + /// + /// + /// + public static IObservable> FlattenBufferResult(this IObservable>> source) + where T : notnull => source.Where(x => x.Count != 0).Select(updates => new ChangeSet(updates.SelectMany(u => u))); +} diff --git a/src/DynamicData/List/ObservableListEx.ForEachChange.cs b/src/DynamicData/List/ObservableListEx.ForEachChange.cs new file mode 100644 index 000000000..f4066e413 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ForEachChange.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Invokes once for every in each changeset. Range changes + /// (AddRange, RemoveRange, Clear) are delivered as a single ; they are not flattened into per-item changes. + /// The changeset is forwarded downstream unchanged. + /// + /// The type of items in the list. + /// The source to observe each change in. + /// The action invoked for each . + /// A continuation of the source changeset stream. + /// or is . + /// + /// This is a side-effect operator. It does not modify the changeset. If you need each individual item from range operations flattened out, use instead. + /// + /// EventBehavior + /// Add/Replace/Remove/Moved/RefreshCallback invoked with the (single-item change). Changeset forwarded. + /// AddRange/RemoveRange/ClearCallback invoked once with the containing the range (accessible via Range property). Changeset forwarded. + /// OnErrorIf the callback throws, the exception propagates as OnError. + /// + /// + /// + /// + /// + /// + public static IObservable> ForEachChange(this IObservable> source, Action> action) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + action.ThrowArgumentNullExceptionIfNull(nameof(action)); + + return source.Do(changes => changes.ForEach(action)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.ForEachItemChange.cs b/src/DynamicData/List/ObservableListEx.ForEachItemChange.cs new file mode 100644 index 000000000..f39760fac --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ForEachItemChange.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Invokes for every individual in each changeset. + /// Range changes are flattened into individual item changes first, so the callback only receives Add, Replace, Remove, and Refresh. + /// + /// The type of items in the list. + /// The source to observe each item-level change in. + /// The action invoked for each individual item change. + /// A continuation of the source changeset stream. + /// or is . + /// + /// + /// Unlike , this operator flattens + /// AddRange, RemoveRange, and Clear into individual entries before invoking the callback. + /// + /// + /// + public static IObservable> ForEachItemChange(this IObservable> source, Action> action) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + action.ThrowArgumentNullExceptionIfNull(nameof(action)); + + return source.Do(changes => changes.Flatten().ForEach(action)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.GroupOn.cs b/src/DynamicData/List/ObservableListEx.GroupOn.cs new file mode 100644 index 000000000..180c98ff6 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.GroupOn.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Groups source items by the value returned by . Each group is an + /// containing an inner observable list of its members. + /// + /// The type of items in the list. + /// The type of the group key. + /// The source to group. + /// A function that returns the group key for each item. + /// An optional of that forces all items to be re-evaluated against when it fires. Useful for time-based groupings (e.g., "Last Hour", "Today"). + /// A list changeset stream of objects, each containing the items belonging to that group. + /// or is . + /// + /// + /// Groups are created lazily and removed when empty. Each group exposes an inner observable list that receives incremental updates. + /// + /// + /// EventBehavior + /// Add/AddRangeGroup key evaluated. Item added to its group. If the group is new, an Add of the group is emitted. + /// ReplaceGroup key re-evaluated. If the group changed, the item is removed from the old group and added to the new one. Empty old groups are removed. + /// Remove/RemoveRange/ClearItem removed from its group. Empty groups are removed from the result. + /// RefreshGroup key re-evaluated. If changed, the item moves between groups. + /// MovedNot handled by group logic. + /// Regrouper firesAll items re-evaluated. Items that changed group key are moved between groups. Empty groups removed, new groups added. + /// + /// + /// + /// + /// + public static IObservable>> GroupOn(this IObservable> source, Func groupSelector, IObservable? regrouper = null) + where TObject : notnull + where TGroup : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + groupSelector.ThrowArgumentNullExceptionIfNull(nameof(groupSelector)); + + return new GroupOn(source, groupSelector, regrouper).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.GroupOnProperty.cs b/src/DynamicData/List/ObservableListEx.GroupOnProperty.cs new file mode 100644 index 000000000..4bf1bb98a --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.GroupOnProperty.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Groups items by a property value, automatically re-grouping when the specified property changes on any item. + /// Each group contains an inner observable list. + /// + /// The type of the object. Must implement . + /// The type of the group key. + /// The source to group by property value. + /// selecting the property whose value determines the group key. + /// An optional throttle duration for property change notifications. + /// The used when throttling. + /// A list changeset stream of objects. + /// or is . + /// + /// + /// Convenience operator equivalent to .AutoRefresh(propertySelector).GroupOn(item => property). + /// Property changes trigger re-evaluation of the group key, potentially moving items between groups. + /// + /// + /// + /// + /// + public static IObservable>> GroupOnProperty(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TGroup : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + return new GroupOnProperty(source, propertySelector, propertyChangedThrottle, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.GroupOnPropertyWithImmutableState.cs b/src/DynamicData/List/ObservableListEx.GroupOnPropertyWithImmutableState.cs new file mode 100644 index 000000000..dad726079 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.GroupOnPropertyWithImmutableState.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Groups items by a property value, automatically re-grouping when the specified property changes. + /// Each group emits immutable snapshots (not live observable lists). + /// + /// The type of the object. Must implement . + /// The type of the group key. + /// The source to group by property value with immutable snapshots. + /// selecting the property whose value determines the group key. + /// An optional throttle duration for property change notifications. + /// The used when throttling. + /// A list changeset stream of immutable group snapshots. + /// or is . + /// + /// + /// Combines + /// with . + /// Unlike , + /// this produces immutable snapshots per group rather than live inner observable lists. + /// + /// + /// + /// + public static IObservable>> GroupOnPropertyWithImmutableState(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TGroup : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + return new GroupOnPropertyWithImmutableState(source, propertySelector, propertyChangedThrottle, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.GroupWithImmutableState.cs b/src/DynamicData/List/ObservableListEx.GroupWithImmutableState.cs new file mode 100644 index 000000000..b607a56f4 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.GroupWithImmutableState.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Groups source items by the value returned by . Each update produces immutable grouping snapshots + /// rather than live inner observable lists. + /// + /// The type of items in the list. + /// The type of the group key. + /// The source to group with immutable snapshots. + /// A function that returns the group key for each item. + /// An optional of that forces all items to be re-evaluated when it fires. + /// A list changeset stream of immutable snapshots. + /// or is . + /// + /// + /// Works like + /// but each affected group emits a new immutable snapshot on every change rather than updating a live inner list. + /// This is useful when consumers need thread-safe, point-in-time snapshots of each group. + /// + /// + /// + /// + public static IObservable>> GroupWithImmutableState(this IObservable> source, Func groupSelectorKey, IObservable? regrouper = null) + where TObject : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); + + return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.LimitSizeTo.cs b/src/DynamicData/List/ObservableListEx.LimitSizeTo.cs new file mode 100644 index 000000000..fd01dcfa6 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.LimitSizeTo.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Limits the source list to a maximum number of items using FIFO eviction. + /// When the list exceeds , the oldest items are removed. + /// Returns an observable of the items that were removed. + /// + /// The type of the item. + /// The source list to apply size limits to. + /// The maximum number of items allowed. Must be greater than zero. + /// The scheduler for scheduling size checks. Defaults to . + /// An observable that emits collections of items each time excess items are removed from the source list. + /// is . + /// is zero or negative. + /// + /// + /// This operator acts directly on an . It subscribes to the source's changes, + /// tracks insertion order using an internal Transform, and removes the oldest items when the size limit is exceeded. + /// + /// Worth noting: The returned observable emits the removed items (not changesets). Subscribe to this observable to activate the size-limiting mechanism. Removal is performed synchronously under a lock shared with the change tracking. + /// + /// + /// + public static IObservable> LimitSizeTo(this ISourceList source, int sizeLimit, IScheduler? scheduler = null) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (sizeLimit <= 0) + { + throw new ArgumentException("sizeLimit cannot be zero", nameof(sizeLimit)); + } + + var locker = InternalEx.NewLock(); + var limiter = new LimitSizeTo(source, sizeLimit, scheduler ?? GlobalConfig.DefaultScheduler, locker); + + return limiter.Run().Synchronize(locker).Do(source.RemoveMany); + } +} diff --git a/src/DynamicData/List/ObservableListEx.MergeChangeSets.cs b/src/DynamicData/List/ObservableListEx.MergeChangeSets.cs new file mode 100644 index 000000000..4f58c05cc --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.MergeChangeSets.cs @@ -0,0 +1,217 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// + /// Merges multiple list changeset streams from an observable-of-observables into a single unified changeset stream. + /// Unlike , list merging performs no key-based deduplication. + /// + /// The source of nested changeset observables. + /// An optional used by the merge tracker to compare items. + public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer? equalityComparer = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new MergeChangeSets(source, equalityComparer).Run(); + } + + /// + /// + /// Merges two list changeset streams into a single unified stream. + /// + /// The first to merge. + /// The second to merge with. + /// An optional used to compare items. + /// An optional for scheduling enumeration. + /// When (default), the result completes when all sources complete. + public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + other.ThrowArgumentNullExceptionIfNull(nameof(other)); + + return new[] { source, other }.MergeChangeSets(equalityComparer, scheduler, completable); + } + + /// + /// + /// Merges the source list changeset stream with additional changeset streams into a single unified stream. + /// + /// The primary source to merge. + /// The additional of list changeset streams to merge with. + /// An optional used to compare items. + /// An optional for scheduling enumeration. + /// When (default), the result completes when all sources complete. + public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, scheduler, completable); + } + + /// + /// Merges a collection of list changeset streams into a single unified changeset stream. + /// This is the canonical list MergeChangeSets overload: other overloads accepting , , or pair/params variants ultimately produce equivalent behavior. + /// + /// The type of items in the list. + /// The collection of list changeset streams to merge. + /// An optional used by the merge tracker to compare items. Defaults to when . + /// An optional for scheduling enumeration. + /// When (default), the result completes when all sources complete. + /// A single list changeset stream containing all changes from all sources. + /// is . + /// + /// + /// All changes from inner streams are forwarded to the output. There is no key-based deduplication (unlike ): if the same item appears in multiple inner streams, it will appear multiple times in the merged output. + /// + /// + /// EventBehavior + /// Add/AddRangeForwarded to the merged output. + /// ReplaceThe old value is replaced by the new value in the merged output. If the old value is not found (by ), the new value is added instead. + /// Remove/RemoveRange/ClearForwarded to the merged output. + /// RefreshForwarded to the merged output. + /// MovedIgnored. + /// + /// + /// + /// + /// + /// + public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new MergeChangeSets(source, equalityComparer, completable, scheduler).Run(); + } + + /// + /// + /// Merges list changeset streams from an into a single stream. Sources can be added or removed dynamically. + /// + public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().MergeChangeSets(equalityComparer); + } + + /// + /// + /// Merges list changeset streams from a list-of-list-changeset-observables into a single stream. + /// Each inner list changeset observable in the source list is merged, and parent item removal triggers child cleanup. + /// + public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeManyChangeSets(static src => src, equalityComparer); + } + + /// + /// Merges cache changeset streams from an into a single cache changeset stream. + /// Uses to resolve conflicts when the same key appears in multiple child streams. + /// + /// The type of items in the list. + /// The type of the object key. + /// The of cache changeset observables. + /// to resolve which value wins when the same key appears in multiple sources. + /// A single cache changeset stream with key-based deduplication. + /// is . + /// + /// Sources can be added or removed dynamically from the observable list. Parent item removal triggers cleanup of all child items from that source. + /// + /// EventBehavior + /// Add (child)If the destination key is new, an Add is emitted. If another source already contributed a child with the same key, resolves the conflict (lowest-ordered value wins). The losing value is tracked internally but not emitted. + /// Update (child)If this source currently owns the destination key downstream, an Update is emitted. Otherwise re-evaluates all sources; a different source's value may win, producing an Update to that value instead. + /// Remove (child)If this source's value was the one published downstream for that destination key, the operator scans other sources for the same key. If found, an Update is emitted with the replacement (per ). Otherwise a Remove is emitted. + /// Refresh (child)If the child item is the one currently published downstream, the Refresh is forwarded. Otherwise re-evaluates all sources; if a different value now wins, an Update is emitted instead. + /// Source list AddSubscribes to the new child changeset stream and merges its keys into the output. + /// Source list RemoveDisposes that source's subscription. All keys it contributed are removed. For keys also contributed by other sources, the next-best value (per ) is promoted as an Update, not an Add. + /// + /// + /// + /// + public static IObservable> MergeChangeSets(this IObservableList>> source, IComparer comparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().MergeChangeSets(comparer); + } + + /// + /// + /// Merges cache changeset streams from an into a single cache changeset stream, with optional equality and ordering comparers. + /// + /// The of cache changeset observables. + /// An optional to determine if two elements are the same. + /// An optional to resolve conflicts when the same key appears in multiple sources. + public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().MergeChangeSets(equalityComparer, comparer); + } + + /// + /// + /// Merges cache changeset streams from a list changeset of cache changeset observables, using a comparer for conflict resolution. + /// + /// The source whose items are cache changeset observables. + /// to resolve which value wins when the same key appears in multiple sources. + public static IObservable> MergeChangeSets(this IObservable>>> source, IComparer comparer) + where TObject : notnull + where TKey : notnull + { + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.MergeChangeSets(comparer); + } + + /// + /// + /// Merges cache changeset streams from a list changeset of cache changeset observables, with optional equality and ordering comparers. + /// + /// The source whose items are cache changeset observables. + /// An optional to determine if two elements are the same. + /// An optional to resolve conflicts when the same key appears in multiple sources. + public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeManyChangeSets(static src => src, equalityComparer, comparer); + } +} diff --git a/src/DynamicData/List/ObservableListEx.MergeMany.cs b/src/DynamicData/List/ObservableListEx.MergeMany.cs new file mode 100644 index 000000000..55dfa4f19 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.MergeMany.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Subscribes to a per-item observable for each item in the source and merges all emissions into a single stream. + /// This is NOT a changeset operator: it returns a flat observable of values. + /// + /// The type of items in the source list. + /// The type of values emitted by per-item observables. + /// The source whose items each produce an observable. + /// A function that returns an observable for each source item. + /// An observable that emits values from all per-item observables, merged together. + /// or is . + /// + /// + /// Event (source)Subscription behavior + /// Add/AddRangeSubscribes to the per-item observable. Emissions are merged into the output. + /// ReplaceOld subscription disposed, new subscription created for the replacement item. + /// Remove/RemoveRange/ClearSubscription disposed. + /// Refresh/MovedNo effect on subscriptions. + /// OnCompleted (source)Completes only after the source and all active inner observables have completed. + /// + /// + /// + /// + /// + /// + public static IObservable MergeMany(this IObservable> source, Func> observableSelector) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeMany(source, observableSelector).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.MergeManyChangeSets.cs b/src/DynamicData/List/ObservableListEx.MergeManyChangeSets.cs new file mode 100644 index 000000000..e10118cef --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.MergeManyChangeSets.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Transforms each source item into a child list changeset stream using , + /// then merges all child streams into a single flat list changeset stream. Parent item removal cleans up all associated children. + /// + /// The type of items in the source list. + /// The type of items in the child changeset streams. + /// The source whose items each produce a child changeset stream. + /// A function that returns a child list changeset stream for each source item. + /// An optional used to compare child items. + /// A single list changeset stream containing all items from all child streams. + /// or is . + /// + /// + /// Internally subscribes to each child stream when a source item is added and disposes the subscription when it is removed. + /// All child items from a removed parent are removed from the merged output. + /// + /// + /// Event (source)Behavior + /// Add/AddRangeSubscribes to the child stream. Child emissions are merged into the output. + /// ReplaceOld child subscription disposed (and its items removed from output). New child subscription created. + /// Remove/RemoveRange/ClearChild subscription disposed. All child items from that parent are removed. + /// + /// + /// + /// + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) + 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, equalityComparer).Run(); + } + + /// + /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. + /// Uses to resolve key conflicts when the same key appears in multiple child streams. + /// + /// The type of items in the source list. + /// The type of items in the child cache changeset streams. + /// The type of the key in the child cache changesets. + /// The source whose items each produce a child changeset stream. + /// A function that returns a child cache changeset stream for each source item. + /// to resolve which value wins when the same key appears from multiple children. + /// A single cache changeset stream with key-based deduplication. + /// , , or is . + /// + /// + /// Delegates to with a equality comparer. + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) + where TObject : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.MergeManyChangeSets(observableSelector, equalityComparer: null, comparer: comparer); + } + + /// + /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. + /// This is the primary list-to-cache MergeManyChangeSets overload. + /// + /// The type of items in the source list. + /// The type of items in the child cache changeset streams. + /// The type of the key in the child cache changesets. + /// The source whose items each produce a child changeset stream. + /// A function that returns a child cache changeset stream for each source item. + /// An optional to determine if two elements are the same. + /// An optional to resolve conflicts when the same key appears from multiple children. + /// A single cache changeset stream with key-based deduplication. + /// or is . + /// + /// + /// Each source item produces a keyed child stream via . All child items are tracked by key. + /// When a parent item is removed, all its child items are removed from the merged output. + /// When the same key appears from multiple children, determines which value wins. + /// + /// + /// Event (source)Behavior + /// Add/AddRangeSubscribes to the child cache stream. Child key/value pairs are merged into the output cache. + /// ReplaceOld child subscription disposed (and its keys removed from output). New child subscription created. + /// Remove/RemoveRange/ClearChild subscription disposed. All keys originating from that child are removed from the output. + /// Moved/RefreshIgnored; this operator emits a cache changeset and source ordering/refresh does not affect key membership. + /// + /// + /// Error and completion: + /// + /// + /// EventBehavior + /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. + /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. + /// + /// + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.NotEmpty.cs b/src/DynamicData/List/ObservableListEx.NotEmpty.cs new file mode 100644 index 000000000..efc183cb6 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.NotEmpty.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Suppresses empty changesets from the stream. Only changesets with at least one change are forwarded. + /// + /// The type of the item. + /// The source to suppress empty changesets. + /// A list changeset stream with empty changesets filtered out. + /// is . + /// + /// + public static IObservable> NotEmpty(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Where(s => s.Count != 0); + } +} diff --git a/src/DynamicData/List/ObservableListEx.OnItemAdded.cs b/src/DynamicData/List/ObservableListEx.OnItemAdded.cs new file mode 100644 index 000000000..f07beed67 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.OnItemAdded.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Invokes for every item added to the source list stream. + /// Triggers on , , and the new item of . + /// + /// The type of items in the list. + /// The source to observe item additions in. + /// The action to invoke for each added item. + /// A continuation of the source changeset stream, with the side effect applied before forwarding. + /// or is . + /// + /// The action fires before the changeset is forwarded downstream. + /// + /// EventBehavior + /// AddCallback invoked with the added item. Changeset forwarded. + /// AddRangeCallback invoked for each item in the range. Changeset forwarded. + /// ReplaceCallback invoked for the new (replacement) item. Changeset forwarded. + /// Remove/RemoveRange/ClearNo callback. Changeset forwarded. + /// Moved/RefreshNo callback. Changeset forwarded. + /// OnErrorIf the callback throws, the exception propagates as OnError. + /// + /// + /// + /// + /// + /// + public static IObservable> OnItemAdded( + this IObservable> source, + Action addAction) + where T : notnull + => List.Internal.OnItemAdded.Create( + source: source, + addAction: addAction); +} diff --git a/src/DynamicData/List/ObservableListEx.OnItemRefreshed.cs b/src/DynamicData/List/ObservableListEx.OnItemRefreshed.cs new file mode 100644 index 000000000..53824aba0 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.OnItemRefreshed.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Invokes for every item with a change in the source stream. + /// + /// The type of items in the list. + /// The source to observe item refresh events in. + /// The action to invoke for each refreshed item. + /// A continuation of the source changeset stream, with the side effect applied before forwarding. + /// or is . + /// + /// + /// + /// + public static IObservable> OnItemRefreshed( + this IObservable> source, + Action refreshAction) + where T : notnull + => List.Internal.OnItemRefreshed.Create( + source: source, + refreshAction: refreshAction); +} diff --git a/src/DynamicData/List/ObservableListEx.OnItemRemoved.cs b/src/DynamicData/List/ObservableListEx.OnItemRemoved.cs new file mode 100644 index 000000000..7a63f9247 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.OnItemRemoved.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Invokes for every item removed from the source list stream. + /// Triggers on , , , and the old item of . + /// + /// The type of items in the list. + /// The source to observe item removals in. + /// The action to invoke for each removed item. + /// When (default), is also invoked for all remaining tracked items upon stream disposal, completion, or error. + /// A continuation of the source changeset stream, with the side effect applied before forwarding. + /// or is . + /// + /// + /// When is , the operator tracks all items that have been added but not yet removed, + /// and fires for each of them during finalization. This is useful for resource cleanup patterns. + /// + /// + /// EventBehavior + /// Add/AddRangeTracked internally (when is ). No callback invoked. Changeset forwarded. + /// ReplaceCallback invoked for the previous (replaced) item. New item tracked. Changeset forwarded. + /// RemoveCallback invoked for the removed item. Changeset forwarded. + /// RemoveRange/ClearCallback invoked for each removed item. Changeset forwarded. + /// Moved/RefreshNo callback. Changeset forwarded. + /// OnErrorIf is , callback is invoked for all tracked items before the error propagates. + /// OnCompletedIf is , callback is invoked for all tracked items before completion propagates. + /// + /// Worth noting: When is (the default), disposing the subscription also invokes the callback for every item still in the list, not just items that were explicitly removed during the subscription. Exceptions in are not caught. + /// + /// + /// + /// + /// + public static IObservable> OnItemRemoved( + this IObservable> source, + Action removeAction, + bool invokeOnUnsubscribe = true) + where T : notnull + => List.Internal.OnItemRemoved.Create( + source: source, + removeAction: removeAction, + invokeOnUnsubscribe: invokeOnUnsubscribe); +} diff --git a/src/DynamicData/List/ObservableListEx.Or.cs b/src/DynamicData/List/ObservableListEx.Or.cs new file mode 100644 index 000000000..b47acd986 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Or.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// + /// Applies a logical OR (union) between a pre-built collection of list changeset sources. Items present in any source are included. + /// + /// + public static IObservable> Or(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// Applies a logical OR (union) between the source and other list changeset streams. + /// Items present in any of the sources are included in the result, using reference-counted equality. + /// + /// The type of the item. + /// The primary source to union. + /// The other changeset streams to combine with. + /// A list changeset stream containing items that exist in at least one source. + /// is . + /// + /// + /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it first appears in any source and removed when it no longer exists in any source. + /// Moved changes are ignored by the set logic. + /// + /// + /// EventBehavior + /// Add/AddRange (any source)If the item is new to the result, an Add is emitted. Otherwise the reference count is incremented. + /// Remove/RemoveRange/Clear (any source)Reference count decremented. If count reaches zero, a Remove is emitted. + /// ReplaceOld item reference count decremented, new item reference count incremented. Add/Remove emitted as needed. + /// RefreshForwarded if the item is in the result set. + /// MovedIgnored. + /// + /// + /// + /// + /// + /// + public static IObservable> Or(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.Or, others); + } + + /// + /// + /// Dynamic OR: sources can be added or removed from the at runtime. + /// + public static IObservable> Or(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// + /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Or(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// + /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Or(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Or); +} diff --git a/src/DynamicData/List/ObservableListEx.Page.cs b/src/DynamicData/List/ObservableListEx.Page.cs new file mode 100644 index 000000000..da8dc2c3d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Page.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies page-based windowing to the source list. Only items within the current page (determined by page number and page size from ) are included downstream. + /// + /// The type of the item. + /// The source to page. + /// An observable of controlling which page to display (page number and page size). + /// An stream containing only items within the current page window. + /// or is . + /// + /// + /// Maintains the full source list internally and calculates the page window on each change or page request. + /// Items entering the page window produce Add; items leaving produce Remove. A new page request triggers + /// a full recalculation of the page contents. + /// + /// Worth noting: Duplicate items are removed from the result via Distinct() using the default equality comparer for , regardless of source order. The source should ideally be sorted before paging, since list order determines which items fall within each page window. + /// + /// + /// + /// + public static IObservable> Page(this IObservable> source, IObservable requests) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); + + return new Pager(source, requests).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.PopulateInto.cs b/src/DynamicData/List/ObservableListEx.PopulateInto.cs new file mode 100644 index 000000000..d5431ff4a --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.PopulateInto.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Subscribes to the source changeset stream and pipes all changes into the . + /// + /// The type of the object. + /// The source to pipe into a target list. + /// The destination to receive all changes. + /// An representing the subscription. Dispose to stop piping changes. + /// or is . + /// + /// Each changeset is applied to the destination using Clone() inside an Edit() call, producing a single batch update per changeset. + /// + /// + /// + /// + public static IDisposable PopulateInto(this IObservable> source, ISourceList destination) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); + } +} diff --git a/src/DynamicData/List/ObservableListEx.QueryWhenChanged.cs b/src/DynamicData/List/ObservableListEx.QueryWhenChanged.cs new file mode 100644 index 000000000..0e9984894 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.QueryWhenChanged.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Emits a projected value from the current list snapshot after every changeset. + /// The receives an representing the current state. + /// + /// The type of items in the list. + /// The type of the projected result. + /// The source to project on each change. + /// A function projecting the current list snapshot to a result value. + /// An observable emitting the projected value after each changeset. + /// or is . + /// + /// Delegates to and applies via Select. + /// + /// + /// + /// + public static IObservable QueryWhenChanged(this IObservable> source, Func, TDestination> resultSelector) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return source.QueryWhenChanged().Select(resultSelector); + } + + /// + /// Emits an snapshot of the current list state after every changeset. + /// Maintains an internal list updated by cloning each changeset. + /// + /// The type of items in the list. + /// The source to project on each change. + /// An observable emitting the full list snapshot as after each change. + /// is . + /// + /// This is a non-changeset operator. It emits the entire collection state on each change, not incremental diffs. + /// Worth noting: A new snapshot is emitted on every changeset, which can be chatty. The collection is rebuilt by cloning each changeset into an internal list. For sorted output, use . + /// + /// + /// + /// + public static IObservable> QueryWhenChanged(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new QueryWhenChanged(source).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.RefCount.cs b/src/DynamicData/List/ObservableListEx.RefCount.cs new file mode 100644 index 000000000..4e8f03a10 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.RefCount.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Reference-counted materialization of the source changeset stream into an . + /// The shared list is created on the first subscriber and disposed when the last subscriber unsubscribes. + /// + /// The type of the item. + /// The source to share via reference counting. + /// A list changeset stream backed by a shared, reference-counted . + /// is . + /// + /// Equivalent to Publish().RefCount() for changeset streams. The underlying list is created lazily on first subscription. + /// + /// + public static IObservable> RefCount(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new RefCount(source).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.RemoveIndex.cs b/src/DynamicData/List/ObservableListEx.RemoveIndex.cs new file mode 100644 index 000000000..9f0e434f7 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.RemoveIndex.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Strips index information from all changes in the stream. + /// + /// The type of the object. + /// The source to strip index information. + /// A list changeset stream with all index values removed from changes. + /// is . + /// + /// Removes index positions from every change in each changeset. This is useful when downstream operators do not require or support index-based operations. + /// + /// + public static IObservable> RemoveIndex(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select(changes => new ChangeSet(changes.YieldWithoutIndex())); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Reverse.cs b/src/DynamicData/List/ObservableListEx.Reverse.cs new file mode 100644 index 000000000..757954e35 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Reverse.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Reverses the order of items in the changeset stream by transforming all indices: new_index = length - old_index - 1. + /// + /// The type of the item. + /// The source to reverse. + /// A list changeset stream with all index positions reversed. + /// is . + /// + /// This is a pure index transformation. The items themselves are unchanged; only their positional indices are inverted. + /// + /// + public static IObservable> Reverse(this IObservable> source) + where T : notnull + { + var reverser = new Reverser(); + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select(changes => new ChangeSet(reverser.Reverse(changes))); + } +} diff --git a/src/DynamicData/List/ObservableListEx.SkipInitial.cs b/src/DynamicData/List/ObservableListEx.SkipInitial.cs new file mode 100644 index 000000000..a7d50fbf0 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.SkipInitial.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Skips the initial changeset (the snapshot emitted on subscription) and forwards all subsequent changesets. + /// Internally defers until loaded, then skips the first emission. + /// + /// The type of the object. + /// The source to skip the initial changeset. + /// A list changeset stream that omits the initial snapshot. + /// is . + /// + /// + /// Warning: This operator assumes the initial changeset is empty. If the source emits a non-empty + /// initial snapshot, those items are silently dropped while downstream consumers remain unaware of them. + /// Any later Refresh, Replace, Remove, or Moved change targeting one of those + /// dropped items will throw because the downstream collection has no record of them. Only use this against + /// a source you know starts empty (for example, a that has not yet been populated). + /// + /// + /// + /// + public static IObservable> SkipInitial(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.DeferUntilLoaded().Skip(1); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Sort.cs b/src/DynamicData/List/ObservableListEx.Sort.cs new file mode 100644 index 000000000..c0c67e484 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Sort.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Sorts the list using the specified comparer, maintaining a sorted output that incrementally updates as items change. + /// + /// The type of the item. + /// The source to sort. + /// The used for sorting. + /// The for improved performance when sorted values are immutable. + /// An optional of that forces a full re-sort when it fires. Required when sorted property values are mutable. + /// An optional of that replaces the comparer, triggering a full re-sort. + /// When the number of changes exceeds this threshold, a full reset is performed instead of incremental updates. Default is 50. + /// A list changeset stream with items in sorted order. + /// or is . + /// + /// + /// Maintains an internal sorted list. Each incoming change is applied incrementally: adds are inserted at the correct sorted position, + /// removes are removed by index, and refreshes re-evaluate position (emitting Moved if changed). + /// + /// + /// EventBehavior + /// Add/AddRangeInserted at the correct sorted position. May trigger a full reset if the count exceeds . + /// ReplaceOld item removed, new item inserted at sorted position. + /// Remove/RemoveRange/ClearRemoved from sorted list. + /// RefreshSort position re-evaluated. If position changed, a Moved is emitted. + /// Comparer changedFull re-sort of all items. + /// Re-sort signalFull re-sort using the current comparer. + /// + /// Worth noting: is faster but requires that the values being sorted on never mutate. If they do, use the signal or . + /// + /// + /// + /// + /// + public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptions options = SortOptions.None, IObservable? resort = null, IObservable>? comparerChanged = null, int resetThreshold = 50) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new Sort(source, comparer, options, resort, comparerChanged, resetThreshold).Run(); + } + + /// + /// + /// Sorts the list using an observable comparer. The initial comparer is taken from the first emission; subsequent emissions trigger a full re-sort. + /// + /// + /// Until emits its first comparer, items are sorted using . Downstream still receives changesets immediately; the initial ordering is whatever produces, then a full re-sort happens once the first comparer arrives. + /// + /// The source to sort. + /// An of that emits comparers. The first emission provides the initial sort order; subsequent emissions trigger re-sorts. + /// for controlling sort behavior. + /// An optional of to force a re-sort with the current comparer. + /// The threshold for triggering a full reset instead of incremental updates. + public static IObservable> Sort(this IObservable> source, IObservable> comparerChanged, SortOptions options = SortOptions.None, IObservable? resort = null, int resetThreshold = 50) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparerChanged.ThrowArgumentNullExceptionIfNull(nameof(comparerChanged)); + + return new Sort(source, null, options, resort, comparerChanged, resetThreshold).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.StartWithEmpty.cs b/src/DynamicData/List/ObservableListEx.StartWithEmpty.cs new file mode 100644 index 000000000..91c7100e0 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.StartWithEmpty.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Prepends an empty changeset to the source stream. Useful for initializing downstream consumers that expect an initial emission. + /// + /// The type of item. + /// The source to prepend an empty changeset to. + /// A list changeset stream that begins with an empty changeset. + /// + /// + /// + public static IObservable> StartWithEmpty(this IObservable> source) + where T : notnull => source.StartWith(ChangeSet.Empty); +} diff --git a/src/DynamicData/List/ObservableListEx.SubscribeMany.cs b/src/DynamicData/List/ObservableListEx.SubscribeMany.cs new file mode 100644 index 000000000..6f4745a2a --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.SubscribeMany.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Creates an subscription for each item via when it is added. + /// The subscription is disposed when the item is removed or replaced. All subscriptions are disposed when the stream terminates. + /// The changeset is forwarded downstream unmodified. + /// + /// The type of the object. + /// The source to create a subscription for each item in. + /// A function that creates an for each item. + /// A continuation of the source changeset stream with per-item subscriptions managed as a side effect. + /// or is . + /// + /// + /// EventBehavior + /// Add/AddRangeSubscription created for each item via the factory. Changeset forwarded. + /// ReplaceOld item's subscription disposed, new subscription created. Changeset forwarded. + /// Remove/RemoveRange/ClearSubscriptions for removed items are disposed. Changeset forwarded. + /// Moved/RefreshForwarded. No subscription changes. + /// OnError/OnCompleted/DisposalAll active subscriptions are disposed. + /// + /// + /// + /// + /// + /// + public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); + + return new SubscribeMany(source, subscriptionFactory).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.SuppressRefresh.cs b/src/DynamicData/List/ObservableListEx.SuppressRefresh.cs new file mode 100644 index 000000000..ac040d07f --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.SuppressRefresh.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Suppresses all changes from the stream. All other change reasons pass through. + /// + /// The type of the object. + /// The source to strip refresh events. + /// A list changeset stream with Refresh changes removed. + /// + /// + public static IObservable> SuppressRefresh(this IObservable> source) + where T : notnull => source.WhereReasonsAreNot(ListChangeReason.Refresh); +} diff --git a/src/DynamicData/List/ObservableListEx.Switch.cs b/src/DynamicData/List/ObservableListEx.Switch.cs new file mode 100644 index 000000000..0df10e44b --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Switch.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Subscribes to the latest inner , switching to each new source and clearing the result when switching. + /// This is the changeset-aware equivalent of Rx's , which cannot be applied directly to changeset streams. + /// + /// The type of the object. + /// An observable that emits instances. Each emission triggers a switch to the new list. + /// A list changeset stream reflecting the most recently received inner list. + /// is . + /// + /// Convenience overload that calls Connect() on each inner list, then delegates to . + /// + /// + public static IObservable> Switch(this IObservable> sources) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Select(cache => cache.Connect()).Switch(); + } + + /// + /// Subscribes to the latest inner changeset stream, switching to each new source and clearing the destination when switching. + /// Previous subscriptions are disposed and the result set is emptied before subscribing to the new inner stream. + /// + /// The type of the object. + /// An of changeset streams. The operator subscribes to the latest inner stream. + /// A list changeset stream reflecting the most recently received inner changeset stream. + /// is . + /// + /// + /// On each new inner stream, the operator clears the destination, disposes the previous subscription, and subscribes to the new stream. + /// This is the changeset-aware equivalent of Rx's Switch(). + /// + /// + /// + public static IObservable> Switch(this IObservable>> sources) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new Switch(sources).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.ToCollection.cs b/src/DynamicData/List/ObservableListEx.ToCollection.cs new file mode 100644 index 000000000..072451c92 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ToCollection.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Emits the full collection as an after every changeset. Equivalent to QueryWhenChanged(items => items). + /// + /// The type of items in the list. + /// The source to materialize into a collection on each change. + /// An observable emitting the full collection snapshot after each change. + /// + /// + /// + public static IObservable> ToCollection(this IObservable> source) + where TObject : notnull => source.QueryWhenChanged(items => items); +} diff --git a/src/DynamicData/List/ObservableListEx.ToObservableChangeSet.cs b/src/DynamicData/List/ObservableListEx.ToObservableChangeSet.cs new file mode 100644 index 000000000..1c465dddc --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ToObservableChangeSet.cs @@ -0,0 +1,185 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Bridges an into the DynamicData world by converting each emitted item into a list changeset. + /// Each emission becomes an Add operation in the resulting changeset stream. + /// + /// The type of the object. + /// The source to convert into a changeset stream. + /// An optional for time-based operations (expiry, size limiting). + /// A list changeset stream where each source emission is an Add. + /// is . + /// + /// + /// This is the primary bridge from standard Rx into DynamicData's list changeset model. Each item emitted by + /// is added to an internal list and an Add changeset is emitted. The list grows unboundedly unless size or time limits + /// are specified via other overloads. + /// + /// Worth noting: Source completion and errors are propagated. The internal list is disposed on unsubscribe. + /// + /// + /// + public static IObservable> ToObservableChangeSet( + this IObservable source, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an into a list changeset stream with per-item time-based expiry. + /// Expired items are automatically removed. + /// + /// The source to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// An optional for expiry timers. + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func expireAfter, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an into a list changeset stream with FIFO size limiting. + /// When the list exceeds , the oldest items are removed. + /// + /// The source to convert into a changeset stream. + /// The maximum list size. Supply -1 to disable size limiting. + /// An optional for scheduling removals. + public static IObservable> ToObservableChangeSet( + this IObservable source, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// + /// Bridges an into a list changeset stream with both time-based expiry and FIFO size limiting. + /// + /// The source to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// The maximum list size. Supply -1 to disable size limiting. + /// An optional for expiry timers and size-limit checks. + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream. + /// Each emitted batch becomes an AddRange. + /// + /// The source of to convert into a changeset stream. + /// An optional for time-based operations. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream with FIFO size limiting. + /// + /// The source of to convert into a changeset stream. + /// The maximum list size. Oldest items are removed when the limit is exceeded. + /// An optional for scheduling removals. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream with time-based expiry. + /// + /// The source of to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// An optional for expiry timers. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func expireAfter, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream with both time-based expiry and FIFO size limiting. + /// + /// The source of to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// The maximum list size. Oldest items removed when exceeded. + /// An optional for expiry timers and size-limit checks. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); +} diff --git a/src/DynamicData/List/ObservableListEx.ToSortedCollection.cs b/src/DynamicData/List/ObservableListEx.ToSortedCollection.cs new file mode 100644 index 000000000..860079901 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ToSortedCollection.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Emits a sorted after every changeset, sorted by the value returned by . + /// + /// The type of items in the list. + /// The type of the sort key. + /// The source to materialize into a sorted collection on each change. + /// A function extracting the sort key from each item. + /// The sort direction. Defaults to ascending. + /// An observable emitting a sorted collection snapshot after each change. + /// + /// + /// + /// + public static IObservable> ToSortedCollection(this IObservable> source, Func sort, SortDirection sortOrder = SortDirection.Ascending) + where TObject : notnull => source.QueryWhenChanged(query => sortOrder == SortDirection.Ascending ? new ReadOnlyCollectionLight(query.OrderBy(sort)) : new ReadOnlyCollectionLight(query.OrderByDescending(sort))); + + /// + /// Emits a sorted after every changeset, sorted using the specified . + /// + /// The type of items in the list. + /// The source to materialize into a sorted collection on each change. + /// The used for sorting. + /// An observable emitting a sorted collection snapshot after each change. + /// + /// + public static IObservable> ToSortedCollection(this IObservable> source, IComparer comparer) + where TObject : notnull => source.QueryWhenChanged( + query => + { + var items = query.AsList(); + items.Sort(comparer); + return new ReadOnlyCollectionLight(items); + }); +} diff --git a/src/DynamicData/List/ObservableListEx.Top.cs b/src/DynamicData/List/ObservableListEx.Top.cs new file mode 100644 index 000000000..17a128f99 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Top.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Takes the first items from the source list. Implemented as Virtualise with a fixed window starting at index 0. + /// + /// The type of the item. + /// The source to take the top items. + /// The maximum number of items to include. Must be greater than zero. + /// A virtual changeset stream containing at most items from the beginning of the source. + /// is . + /// is zero or negative. + /// + /// The source should ideally be sorted before applying Top, since list order determines which items appear. + /// + /// + /// + /// + public static IObservable> Top(this IObservable> source, int numberOfItems) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (numberOfItems <= 0) + { + throw new ArgumentOutOfRangeException(nameof(numberOfItems), "Number of items should be greater than zero"); + } + + return source.Virtualise(Observable.Return(new VirtualRequest(0, numberOfItems))); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Transform.cs b/src/DynamicData/List/ObservableListEx.Transform.cs new file mode 100644 index 000000000..64a856a24 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Transform.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Projects each item to a new form using a synchronous transform function. + /// + /// The type of the source items. + /// The type of the destination items. + /// The source to transform. + /// The transform function applied to each item. + /// When , Refresh events re-invoke the factory and emit an update. When (the default), Refresh is forwarded without re-transforming. + /// A list changeset stream of transformed items. + /// + /// + /// Maintains an internal list of transformed items. Each source changeset is + /// processed and a corresponding output changeset is produced with the transformed items. + /// + /// + /// EventBehavior + /// AddThe factory is called and an Add is emitted at the same index. + /// AddRangeThe factory is called for each item. An AddRange is emitted at the same start index. + /// ReplaceThe factory is called for the new item. A Replace is emitted at the same index. The previous transformed value is available to overloads that accept . + /// RemoveA Remove is emitted (no factory call). + /// RemoveRangeA RemoveRange is emitted. + /// MovedA Moved is emitted with updated indices (no factory call). Throws if the source change has no index information. + /// RefreshIf is (default), the Refresh is forwarded without re-transforming. If , the factory is re-invoked and the result replaces the current value. + /// ClearA Clear is emitted and the internal list is emptied. + /// OnErrorIf the factory throws, the exception propagates as OnError. + /// + /// Worth noting: By default, Refresh does NOT re-transform the item (it just forwards the signal). Set to if you need the factory re-invoked on Refresh. Add operations with out-of-bounds indices silently append to the end. + /// + /// or is . + /// + /// + /// + /// + public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((t, _, _) => transformFactory(t), transformOnRefresh); + } + + /// + /// + /// Projects each item using a transform function that also receives the item's index. + /// + public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((t, _, idx) => transformFactory(t, idx), transformOnRefresh); + } + + /// + /// + /// Projects each item using a transform function that also receives the previously transformed value (if any). + /// Type arguments must be specified explicitly as type inference fails for this overload. + /// + public static IObservable> Transform(this IObservable> source, Func, TDestination> transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((t, previous, _) => transformFactory(t, previous), transformOnRefresh); + } + + /// + /// + /// Projects each item using a transform function that receives the source item, the previously transformed value, and the index. + /// Type arguments must be specified explicitly as type inference fails for this overload. + /// + public static IObservable> Transform(this IObservable> source, Func, int, TDestination> transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new Transformer(source, transformFactory, transformOnRefresh).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.TransformAsync.cs b/src/DynamicData/List/ObservableListEx.TransformAsync.cs new file mode 100644 index 000000000..998eb1419 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.TransformAsync.cs @@ -0,0 +1,120 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Projects each item to a new form using an async transform function. Behaves like but the factory returns a . + /// + /// The type of the source items. + /// The type of the destination items. + /// The source to transform asynchronously. + /// An async function that transforms each source item. + /// When , Refresh events re-invoke the factory. + /// A list changeset stream of asynchronously transformed items. + /// or is . + /// + /// Change handling is identical to the synchronous except the factory is awaited. Operations are serialized per changeset via a semaphore. + /// + /// EventBehavior + /// Add/AddRangeThe async factory is awaited for each item. An Add/AddRange is emitted with the transformed results. + /// ReplaceThe async factory is awaited for the new item. A Replace is emitted. + /// Remove/RemoveRangeEmitted without invoking the factory. + /// MovedEmitted with updated indices (no factory call). + /// RefreshIf is (default), forwarded without re-transforming. If , the factory is re-awaited. + /// ClearEmitted and internal list cleared. + /// OnErrorIf the async factory throws, the exception propagates as OnError. + /// OnCompletedForwarded after the last changeset is processed. + /// + /// Worth noting: All async transforms within a single changeset are serialized (not parallel). Each changeset is fully processed before the next begins. By default, Refresh does NOT re-transform. + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((t, _, _) => transformFactory(t), transformOnRefresh); + } + + /// + /// + /// Async transform overload receiving the source item and its index. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((t, _, i) => transformFactory(t, i), transformOnRefresh); + } + + /// + /// + /// Async transform overload receiving the source item and the previously transformed value. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func, Task> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((t, d, _) => transformFactory(t, d), transformOnRefresh); + } + + /// + /// + /// Async transform overload receiving the source item, previously transformed value, and index. This is the terminal overload that all other TransformAsync overloads delegate to. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func, int, Task> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new TransformAsync(source, transformFactory, transformOnRefresh).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.TransformMany.cs b/src/DynamicData/List/ObservableListEx.TransformMany.cs new file mode 100644 index 000000000..6b3e3e327 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.TransformMany.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Flattens each source item into multiple destination items using . Each source item produces zero or more children, + /// all of which are merged into a single flat list changeset stream. + /// + /// The type of the destination items. + /// The type of the source items. + /// The source to expand each item into multiple children. + /// A function that returns the child items for each source item. + /// An optional used during Replace to determine which child items changed between old and new parent values. + /// A list changeset stream of all child items from all source items. + /// or is . + /// + /// + /// EventBehavior + /// Add/AddRangeChildren expanded and added to the output. + /// ReplaceOld children diffed against new children (using ). Removed, added, or kept as appropriate. + /// Remove/RemoveRange/ClearAll children of the removed parents are removed from the output. + /// RefreshChildren re-expanded and diffed. + /// + /// + /// + /// + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + + return new TransformMany(source, manySelector, equalityComparer).Run(); + } + + /// + /// + /// Flattens each source item into children from an . The collection is observed for subsequent changes. + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); + + /// + /// + /// Flattens each source item into children from a . The collection is observed for subsequent changes. + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); + + /// + /// + /// Flattens each source item into children from an . The inner list is observed for subsequent changes. + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); +} diff --git a/src/DynamicData/List/ObservableListEx.Virtualise.cs b/src/DynamicData/List/ObservableListEx.Virtualise.cs new file mode 100644 index 000000000..a2d2ec09d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Virtualise.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies a sliding window to the source list using start index and size from . + /// Only items within the window are included downstream. + /// + /// The type of the item. + /// The source to virtualize. + /// An observable of specifying the start index and size of the window. + /// An stream containing only items within the current virtual window. + /// or is . + /// + /// + /// Like but uses absolute start index and size instead of page number and page size. + /// Internally maintains the full source list and recalculates the window on each change or request. + /// + /// + /// + /// + public static IObservable> Virtualise(this IObservable> source, IObservable requests) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); + + return new Virtualiser(source, requests).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs b/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs new file mode 100644 index 000000000..a6529e3f3 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Watches all items in the source list and emits the item when any of its properties change. + /// Requires to implement . + /// This is NOT a changeset operator: it returns a flat . + /// + /// The type of the object. Must implement . + /// The source to observe property changes on items in. + /// An optional list of property names to monitor. If empty, all property changes are observed. + /// An observable emitting the item whenever any monitored property changes. + /// is . + /// + /// Implemented via . Subscriptions are managed per item: created on add, disposed on remove. + /// + /// + /// + /// + /// + public static IObservable WhenAnyPropertyChanged(this IObservable> source, params string[] propertiesToMonitor) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeMany(t => t.WhenAnyPropertyChanged(propertiesToMonitor)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs b/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs new file mode 100644 index 000000000..4a91bd035 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Watches a specific property on all items in the source list and emits a (item + value pair) when it changes. + /// Requires to implement . + /// This is NOT a changeset operator: it returns a flat . + /// + /// The type of item. Must implement . + /// The type of the property value. + /// The source to observe a specific property on items in. + /// An expression selecting the property to observe. + /// When (default), the current value is emitted immediately upon subscribing to each item. + /// An observable emitting whenever the property changes on any tracked item. + /// or is . + /// + /// Implemented via . + /// + /// + /// + /// + public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + var factory = propertyAccessor.GetFactory(); + return source.MergeMany(t => factory(t, notifyOnInitialValue)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs b/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs new file mode 100644 index 000000000..0328218fc --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Watches a specific property on all items and emits just the property value (without the sender) when it changes. + /// Requires to implement . + /// This is NOT a changeset operator: it returns a flat . + /// + /// The type of item. Must implement . + /// The type of the property value. + /// The source to observe a specific property value on items in. + /// An expression selecting the property to observe. + /// When (default), the current value is emitted immediately upon subscribing to each item. + /// An observable emitting the property value whenever it changes on any tracked item. + /// or is . + /// + /// + /// + public static IObservable WhenValueChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + var factory = propertyAccessor.GetFactory(); + return source.MergeMany(t => factory(t, notifyOnInitialValue).Select(pv => pv.Value)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.WhereReasonsAre.cs b/src/DynamicData/List/ObservableListEx.WhereReasonsAre.cs new file mode 100644 index 000000000..c2e61653d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.WhereReasonsAre.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Filters the changeset stream to include only changes with the specified values. + /// Index information is stripped from the output because removing some changes invalidates the original index positions. + /// + /// The type of the item. + /// The source to filter by change reason. + /// The change reasons to include. Must specify at least one. + /// A list changeset stream containing only changes with the specified reasons. + /// is . + /// is empty. + /// + /// Filters individual changes within each changeset. If filtering removes all changes from a changeset, the empty changeset is suppressed via . + /// Worth noting: Filtering out Remove changes can cause downstream operators to accumulate items indefinitely (memory leak). Index information is stripped because removing some changes invalidates the original index positions. + /// + /// + /// + /// + public static IObservable> WhereReasonsAre(this IObservable> source, params ListChangeReason[] reasons) + where T : notnull + { + reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); + + if (reasons.Length == 0) + { + throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); + } + + var matches = new HashSet(reasons); + return source.Select( + changes => + { + var filtered = changes.Where(change => matches.Contains(change.Reason)).YieldWithoutIndex(); + return new ChangeSet(filtered); + }).NotEmpty(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.WhereReasonsAreNot.cs b/src/DynamicData/List/ObservableListEx.WhereReasonsAreNot.cs new file mode 100644 index 000000000..aabb2b3c2 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.WhereReasonsAreNot.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Filters the changeset stream to exclude changes with the specified values. + /// Index information is stripped from the output because removing some changes invalidates the original index positions. + /// The exception is when only is excluded, since removing Refresh does not affect index calculations. + /// + /// The type of the item. + /// The source to filter by excluding change reasons. + /// The change reasons to exclude. Must specify at least one. + /// A list changeset stream with the specified change reasons removed. + /// is . + /// is empty. + /// + /// + /// Empty changesets (after filtering) are automatically suppressed. When only is excluded, + /// indices are preserved, since removing Refresh does not affect index calculations. + /// + /// + /// + /// + /// + public static IObservable> WhereReasonsAreNot(this IObservable> source, params ListChangeReason[] reasons) + where T : notnull + { + reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); + + if (reasons.Length == 0) + { + throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); + } + + if (reasons.Length == 1 && reasons[0] == ListChangeReason.Refresh) + { + // If only refresh changes are removed, then there's no need to remove the indexes + return source.Select(changes => + { + var filtered = changes.Where(c => c.Reason != ListChangeReason.Refresh); + return new ChangeSet(filtered); + }).NotEmpty(); + } + + var matches = new HashSet(reasons); + return source.Select( + updates => + { + var filtered = updates.Where(u => !matches.Contains(u.Reason)).YieldWithoutIndex(); + return new ChangeSet(filtered); + }).NotEmpty(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Xor.cs b/src/DynamicData/List/ObservableListEx.Xor.cs new file mode 100644 index 000000000..b63b5c6d0 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Xor.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// Extensions for ObservableList. +/// +public static partial class ObservableListEx +{ + /// + /// Applies a logical XOR (symmetric difference) between the source and other streams. + /// Items present in exactly one source are included in the result. + /// + /// The type of the item. + /// The primary source to exclusively combine. + /// The other changeset streams to combine with. + /// A list changeset stream containing items that exist in exactly one source. + /// is . + /// + /// + /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it exists in exactly one source. + /// If it appears in a second source, it is removed from the result. If it then leaves one source, + /// it re-enters the result. Moved changes are ignored. + /// + /// + /// EventBehavior + /// Add/AddRangeReference count updated. If the item is now in exactly one source, an Add is emitted. If now in two or more, a Remove is emitted. + /// Remove/RemoveRange/ClearReference count decremented. If now in exactly one source, an Add is emitted. If now in zero, a Remove is emitted. + /// ReplaceOld item reference count decremented, new item incremented, with Xor logic applied. + /// RefreshForwarded if item is in the result set. + /// MovedIgnored. + /// + /// + /// + /// + /// + /// + public static IObservable> Xor(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.Xor, others); + } + + /// + /// + /// Applies a logical XOR between a pre-built collection of list changeset sources. + /// + public static IObservable> Xor(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + /// + /// + /// Dynamic XOR: sources can be added or removed from the at runtime. + /// + public static IObservable> Xor(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + /// + /// + /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Xor(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + /// + /// + /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Xor(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); +} diff --git a/src/DynamicData/List/ObservableListEx.cs b/src/DynamicData/List/ObservableListEx.cs index 37f3a3966..ffe07170a 100644 --- a/src/DynamicData/List/ObservableListEx.cs +++ b/src/DynamicData/List/ObservableListEx.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 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. @@ -21,2909 +21,6 @@ namespace DynamicData; /// /// Extensions for ObservableList. /// -public static class ObservableListEx +public static partial class ObservableListEx { - /// - /// Injects a side effect into a changeset stream via an . - /// The adaptor's Adapt method is invoked for each changeset before it is forwarded downstream unchanged. - /// - /// The type of items in the list. - /// The source to observe and adapt. - /// The adaptor whose Adapt method is invoked for each changeset. - /// A list changeset stream identical to the source, with the adaptor side effect applied. - /// or is . - /// - /// - /// This is the primary extension point for custom UI binding adaptors (e.g., - /// delegates to this operator). If the adaptor throws, the exception propagates downstream as OnError. - /// - /// - /// - public static IObservable> Adapt(this IObservable> source, IChangeSetAdaptor adaptor) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); - - return Observable.Create>( - observer => - { - var locker = InternalEx.NewLock(); - return source.Synchronize(locker).Select( - changes => - { - adaptor.Adapt(changes); - return changes; - }).SubscribeSafe(observer); - }); - } - - /// - /// Adds a key to each item in a list changeset, converting it to a cache changeset that supports all keyed DynamicData operators. - /// - /// The type of items in the list. - /// The type of the key. - /// The source to add keys to, converting to a cache changeset. - /// A function to extract a unique key from each item. - /// A cache changeset stream with keyed items. - /// or is . - /// - /// - /// All index information is dropped during conversion because cache changesets are unordered by default. - /// Use this when you need to transition from list-based pipelines to cache-based operators (Filter by key, Join, Group, etc.). - /// - /// - /// - public static IObservable> AddKey(this IObservable> source, Func keySelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return source.Select(changes => new ChangeSet(new AddKeyEnumerator(changes, keySelector))); - } - - /// - /// Applies a logical AND (intersection) between multiple list changeset streams. - /// Only items present in ALL sources appear in the result. - /// - /// The type of items in the lists. - /// The first source to intersect. - /// The additional changeset streams to intersect with. - /// A list changeset stream containing items that exist in every source. - /// is . - /// - /// - /// Uses reference counting per item across all sources. An item appears downstream only when - /// its reference count is non-zero in ALL sources. Item identity is determined by the default equality comparer. - /// - /// - /// EventBehavior - /// Add/AddRangeThe item's reference count is incremented in its source tracker. If the item is now present in all sources, an Add is emitted. - /// ReplaceThe old item's reference count is decremented and the new item's is incremented. Depending on whether each is present in ALL sources, this emits an Add, Remove, Replace, or nothing. - /// Remove/RemoveRange/ClearThe item's reference count is decremented. If it was in the result and is no longer in all sources, a Remove is emitted. - /// RefreshForwarded as Refresh if the item is currently in the result. - /// MovedIgnored (set operations are position-independent). - /// - /// Worth noting: Item identity uses object equality, not position. Duplicate items in a single source are reference-counted independently. - /// - /// - /// - /// - /// - public static IObservable> And(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.And, others); - } - - /// - /// A of changeset streams to intersect. - /// - /// - /// This overload accepts a pre-built collection of sources instead of a params array. - /// - public static IObservable> And(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// An of changeset streams. Sources can be added or removed dynamically. - /// - /// - /// This overload supports dynamic source management: adding or removing changeset streams from the observable list triggers re-evaluation. - /// - public static IObservable> And(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// An of . Each inner list's changes are connected automatically. - /// - /// - /// This overload accepts instances directly, calling Connect() internally. - /// - public static IObservable> And(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// An of . Each inner list's changes are connected automatically. - /// - /// - /// This overload accepts instances directly, calling Connect() internally. - /// - public static IObservable> And(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// Wraps a as a read-only , hiding mutation methods. - /// - /// The type of items in the list. - /// The mutable source list to wrap. - /// A read-only observable list that mirrors the source. - /// is . - public static IObservableList AsObservableList(this ISourceList source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new AnonymousObservableList(source); - } - - /// - /// Materializes a changeset stream into a read-only . - /// The list is kept in sync with the source stream for the lifetime of the subscription. - /// - /// The type of items in the list. - /// The source to materialize into a read-only list. - /// A read-only observable list reflecting the current state of the stream. - /// is . - /// - /// - /// This is the primary way to multicast a changeset pipeline. Materializing once into an , - /// then calling Connect() on the result for each downstream consumer, ensures the upstream operators are evaluated only once - /// regardless of how many subscribers consume the result. - /// - /// - /// - public static IObservableList AsObservableList(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new AnonymousObservableList(source); - } - - /// - /// Monitors all properties on each item (via ) and emits Refresh - /// changes when any property changes, causing downstream operators to re-evaluate. - /// - /// The type of items, which must implement . - /// The source to monitor for property-driven refresh signals. - /// An optional buffer duration to batch multiple refresh signals into a single changeset. - /// An optional throttle applied to each item's property change notifications. - /// The scheduler for throttle and buffer timing. Defaults to . - /// A list changeset stream with additional Refresh changes injected when properties change. - /// is . - /// - /// - /// Wraps using WhenAnyPropertyChanged() as the re-evaluator. - /// Pair with or - /// to get reactive re-evaluation on property changes. - /// - /// - /// EventBehavior - /// Add/AddRangeSubscribes to PropertyChanged on each new item. The original change is forwarded. - /// ReplaceUnsubscribes from the old item, subscribes to the new. The original change is forwarded. - /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. - /// Moved/RefreshForwarded unchanged. - /// Property changesA Refresh change is emitted for the item whose property changed. - /// - /// Worth noting: Each item generates a subscription. For large lists with frequent property changes, use and to reduce churn. - /// - /// - /// - /// - /// - public static IObservable> AutoRefresh(this IObservable> source, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.AutoRefreshOnObservable( - t => - { - if (propertyChangeThrottle is null) - { - return t.WhenAnyPropertyChanged(); - } - - return t.WhenAnyPropertyChanged().Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); - }, - changeSetBuffer, - scheduler); - } - - /// - /// Monitors a single property (selected by ) on each item via - /// and emits Refresh changes when that property changes, causing downstream operators to re-evaluate. More efficient than - /// the all-properties overload when only one property (of type ) affects downstream behavior. - /// - /// - public static IObservable> AutoRefresh(this IObservable> source, Expression> propertyAccessor, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - return source.AutoRefreshOnObservable( - t => - { - if (propertyChangeThrottle is null) - { - return t.WhenPropertyChanged(propertyAccessor, false); - } - - return t.WhenPropertyChanged(propertyAccessor, false).Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); - }, - changeSetBuffer, - scheduler); - } - - /// - /// Monitors each item with a custom observable and emits Refresh changes whenever that observable fires, - /// causing downstream operators (Filter, Sort, Group) to re-evaluate. - /// - /// The type of items in the list. - /// The type emitted by the re-evaluator observable (value is ignored). - /// The source to monitor for observable-driven refresh signals. - /// A factory that, given an item, returns an observable whose emissions trigger a Refresh for that item. - /// An optional buffer duration to batch refresh signals into a single changeset. - /// The for buffering. - /// A list changeset stream with additional Refresh changes injected when per-item observables fire. - /// or is . - /// - /// - /// This is the general-purpose refresh mechanism. - /// is a convenience wrapper that uses WhenAnyPropertyChanged() as the re-evaluator. - /// - /// - /// EventBehavior - /// Add/AddRangeSubscribes to the re-evaluator observable for each new item. The original change is forwarded. - /// ReplaceUnsubscribes from the old item's observable, subscribes to the new. The original change is forwarded. - /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. - /// Moved/RefreshForwarded unchanged. - /// Re-evaluator firesThe item's current index is looked up and a Refresh change is emitted. - /// - /// - /// - /// - /// - public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); - - return new AutoRefresh(source, reevaluator, changeSetBuffer, scheduler).Run(); - } - - /// - /// Applies changeset mutations to a target for UI data binding. - /// - /// The type of items in the list. - /// The source to bind to a collection. - /// The target collection to keep in sync. - /// When a changeset exceeds this many changes, the collection is reset instead of applying individual changes. - /// A continuation of the source changeset stream (allows further chaining). - /// or is . - /// - /// - /// Delegates to with an internal collection adaptor. - /// Each changeset is applied to the target collection on the calling thread. For UI binding, ensure the source is - /// observed on the UI thread (e.g., via ObserveOn). - /// - /// - /// EventBehavior - /// AddItem inserted at the specified index in the target collection. - /// AddRangeItems inserted as a range. If the count exceeds , the collection is cleared and repopulated. - /// ReplaceItem at the specified index is replaced. - /// RemoveItem at the specified index is removed. - /// RemoveRange/ClearItems removed from the collection. - /// MovedItem is moved between positions in the collection. - /// RefreshDepends on the adaptor implementation. - /// - /// - /// - /// - /// - /// - /// - public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - - var options = resetThreshold == BindingOptions.DefaultResetThreshold - ? defaults - : defaults with { ResetThreshold = resetThreshold }; - - return source.Bind(targetCollection, options); - } - - /// - /// Binds the source changeset stream to , with fine-grained control over reset threshold and other behaviors. - /// - /// - public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, BindingOptions options) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); - - var adaptor = new ObservableCollectionAdaptor(targetCollection, options); - return source.Adapt(adaptor); - } - - /// - /// Constructs a and binds the changeset stream to it. - /// Use this overload when you need a read-only view (typically for UI binding) without managing the backing collection yourself. - /// The created collection is returned via the output parameter. - /// - /// - /// - /// - /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. - /// - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - var options = resetThreshold == BindingOptions.DefaultResetThreshold - ? defaults - : defaults with { ResetThreshold = resetThreshold }; - - return source.Bind(out readOnlyObservableCollection, options); - } - - /// - /// Constructs a and binds the changeset stream to it, - /// with fine-grained control over reset threshold and other behaviors. - /// The created collection is returned via the output parameter. - /// - /// - /// - /// - /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. - /// - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - var target = new ObservableCollectionExtended(); - var result = new ReadOnlyObservableCollection(target); - var adaptor = new ObservableCollectionAdaptor(target, options); - readOnlyObservableCollection = result; - return source.Adapt(adaptor); - } - -#if SUPPORTS_BINDINGLIST - - /// - /// Binds the source changeset stream to a WinForms , keeping in sync. - /// - /// - public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); - - return source.Adapt(new BindingListAdaptor(bindingList, resetThreshold)); - } - -#endif - - /// - /// - /// - /// This overload starts unpaused and has no timeout. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, IScheduler? scheduler = null) - where T : notnull => BufferIf(source, pauseIfTrueSelector, false, scheduler); - - /// - /// - /// - /// This overload allows setting the initial pause state but has no timeout. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); - - return BufferIf(source, pauseIfTrueSelector, initialPauseState, null, scheduler); - } - - /// - /// - /// - /// This overload starts unpaused and accepts a timeout but not an explicit initial pause state. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, TimeSpan? timeOut, IScheduler? scheduler = null) - where T : notnull => BufferIf(source, pauseIfTrueSelector, false, timeOut, scheduler); - - /// - /// Buffers changeset notifications while a pause signal is active, then flushes all buffered changes when resumed. - /// - /// The type of items in the list. - /// The source to conditionally buffer. - /// An of that controls buffering: pauses (buffers), resumes (flushes). - /// The initial pause state. When , buffering starts immediately. - /// An optional maximum duration to keep the buffer open. After this time, the buffer is flushed regardless of pause state. - /// The for timeout scheduling. - /// A list changeset stream that buffers during pause and emits combined changesets on resume. - /// or is . - /// - /// - /// All changeset events are buffered at the changeset level (not individual changes) while paused. - /// On resume, all buffered changesets are emitted as a single combined changeset. If the buffer is empty on resume, - /// no emission occurs. - /// - /// - /// EventBehavior - /// Any (while paused)Accumulated in an internal buffer. Not emitted downstream. - /// Any (while active)Passed through immediately. - /// Pause selector emits falseAll buffered changesets are flushed downstream as one combined changeset. - /// Timeout firesAutomatically resumes and flushes the buffer. - /// OnErrorForwarded immediately (not buffered). - /// OnCompletedForwarded immediately. - /// - /// Worth noting: Each pause/resume cycle re-arms the timeout. Rapid toggling can create many small buffer windows. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, TimeSpan? timeOut, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); - - return new BufferIf(source, pauseIfTrueSelector, initialPauseState, timeOut, scheduler).Run(); - } - - /// - /// Buffers changesets during an initial time window, then emits a single combined changeset and passes through subsequent changes. - /// - /// The type of items in the list. - /// The source to buffer during the initial loading period. - /// The time period (measured from first emission) during which changes are buffered. - /// The for timing the buffer window. - /// A list changeset stream where the initial burst is combined into one changeset. - /// - /// - /// For a configured duration after the first emission, all changesets are buffered and combined into a single emission. - /// After this initial window, subsequent changesets pass through immediately. - /// - /// - /// - /// - public static IObservable> BufferInitial(this IObservable> source, TimeSpan initialBuffer, IScheduler? scheduler = null) - where TObject : notnull => source.DeferUntilLoaded().Publish( - shared => - { - var initial = shared.Buffer(initialBuffer, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult().Take(1); - - return initial.Concat(shared); - }); - - /// - /// Casts each item in the changeset from object to using a direct cast. - /// - /// The target type to cast to. - /// The source of object items. - /// A list changeset stream of cast items. - /// is . - /// - /// - public static IObservable> Cast(this IObservable> source) - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select(changes => changes.Transform(t => (TDestination)t)); - } - - /// - /// Transforms each item in the changeset using a conversion function. - /// - /// The source item type. - /// The destination item type. - /// The source to cast. - /// A function to convert each item from to . - /// A list changeset stream of converted items. - /// or is . - /// Use this overload when type inference requires explicit specification of both source and destination types. Alternatively, call first, then the single-type-parameter overload. - /// - /// - public static IObservable> Cast(this IObservable> source, Func conversionFactory) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); - - return source.Select(changes => changes.Transform(conversionFactory)); - } - - /// - /// Casts each item in the changeset to object. Typically used before to work around type inference limitations. - /// - /// The source item type (must be a reference type). - /// The source to cast to object. - /// A list changeset stream of object items. - /// - public static IObservable> CastToObject(this IObservable> source) - where T : class => source.Select(changes => changes.Transform(t => (object)t)); - - /// - /// Applies each changeset to the target list as a side effect, keeping it synchronized with the source. - /// - /// The type of items in the list. - /// The source to clone. - /// The target list to clone changes into. - /// A continuation of the source changeset stream. - /// is . - /// - /// Lower-level than . Uses .Clone() to apply all changeset operations directly. - /// - /// - /// - public static IObservable> Clone(this IObservable> source, IList target) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Do(target.Clone); - } - - /// - /// Convert the object using the specified conversion function. - /// This is a lighter equivalent of Transform and is designed to be used with non-disposable objects. - /// - /// The type of items in the list. - /// The type of the destination items. - /// The source to convert. - /// The conversion factory. - /// An observable which emits the change set. - [Obsolete("Prefer Cast as it is does the same thing but is semantically correct")] - public static IObservable> Convert(this IObservable> source, Func conversionFactory) - where TObject : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); - - return source.Select(changes => changes.Transform(conversionFactory)); - } - - /// - /// Defers downstream delivery until the source emits its first changeset, then forwards all subsequent changesets. - /// - /// The type of the object. - /// The source to defer until the first changeset arrives. - /// A list changeset stream that begins emitting only after the source has produced its first changeset. - /// is . - /// - /// - /// Subscribes to the source immediately but buffers internally until the first changeset arrives, at which point it emits - /// the initial data and all subsequent changesets. This is useful when downstream consumers should not receive an empty initial state. - /// - /// - /// - /// - public static IObservable> DeferUntilLoaded(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DeferUntilLoaded(source).Run(); - } - - /// - /// - /// - /// Convenience overload that calls source.Connect().DeferUntilLoaded(). - /// - public static IObservable> DeferUntilLoaded(this IObservableList source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().DeferUntilLoaded(); - } - - /// - /// Disposes items that implement when they are removed, replaced, or cleared from the stream. - /// All remaining tracked items are disposed when the stream finalizes (OnCompleted, OnError, or subscription disposal). - /// - /// The type of the object. - /// The source to track for disposal on removal. - /// A continuation of the source changeset stream with disposal side effects applied. - /// is . - /// - /// - /// Items are cast to and disposed after the changeset has been forwarded downstream. - /// Items that do not implement are silently ignored. - /// - /// - /// EventBehavior - /// Add/AddRangeItems are tracked for future disposal. Changeset forwarded. - /// ReplaceThe previous (replaced) item is disposed after the changeset is forwarded. The new item is tracked. - /// Remove/RemoveRangeRemoved items are disposed after the changeset is forwarded. - /// ClearAll tracked items are disposed after the changeset is forwarded. - /// Moved/RefreshForwarded. No disposal occurs. - /// OnError/OnCompleted/DisposalAll remaining tracked items are disposed during finalization. - /// - /// Worth noting: Disposal happens after the changeset is delivered downstream, so subscribers see the change before items are disposed. - /// - /// - /// - /// - public static IObservable> DisposeMany(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DisposeMany(source).Run(); - } - - /// - /// Extracts distinct values from source items using , with reference counting to track when values enter and leave the result set. - /// - /// The type of items in the source list. - /// The type of distinct values produced. - /// The source to extract distinct values. - /// A function that extracts the value to track from each source item. - /// A list changeset stream of distinct values. - /// or is . - /// - /// - /// Maintains an internal reference count per distinct value. A value is included when its count first exceeds zero - /// and removed when its count drops back to zero. - /// - /// - /// EventBehavior - /// Add/AddRangeValue extracted. If first occurrence, an Add is emitted. Otherwise the reference count is incremented silently. - /// ReplaceOld value's reference count decremented (removed if zero), new value's count incremented (added if first). If the value did not change, no emission. - /// Remove/RemoveRangeReference count decremented. If the count reaches zero, a Remove is emitted for that distinct value. - /// RefreshValue is re-extracted. If changed, old value decremented and new value incremented (same as Replace logic). - /// ClearAll reference counts cleared. Remove emitted for every tracked distinct value. - /// - /// - /// - public static IObservable> DistinctValues(this IObservable> source, Func valueSelector) - where TObject : notnull - where TValue : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - valueSelector.ThrowArgumentNullExceptionIfNull(nameof(valueSelector)); - - return new Distinct(source, valueSelector).Run(); - } - - /// - /// Applies a logical set-difference (Except) between the source and other streams. - /// Items present in the first source but not in any of the are included in the result. - /// - /// The type of the item. - /// The primary from which other streams are subtracted. - /// The other changeset streams to exclude from the result. - /// A list changeset stream containing items from that are not in any of . - /// is . - /// - /// - /// Item identity is determined by the default equality comparer for . Across all sources, items are tracked - /// by reference-counted equality (not by index position). - /// The first source has a special role: only items from it can appear in the result, and only if they do not exist in any other source. - /// - /// - /// EventBehavior - /// Add/AddRange (first source)If the item does not exist in any other source, an Add is emitted. - /// Add/AddRange (other source)If the item was in the result (from first source), a Remove is emitted. - /// Remove/RemoveRange/Clear (first source)If the item was in the result, a Remove is emitted. - /// Remove/RemoveRange/Clear (other source)If the item exists in the first source and no longer in any other, an Add is emitted. - /// ReplaceTreated as a Remove of the old item plus an Add of the new item, with set logic re-evaluated. - /// MovedIgnored by the set logic (no positional semantics). - /// RefreshForwarded if the item is currently in the result set. - /// - /// Worth noting: Unlike , the first source is asymmetric: only its items can appear in the result. - /// - /// - /// - /// - /// - public static IObservable> Except(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.Except, others); - } - - /// - /// - /// - /// Static overload accepting a pre-built collection of sources. The first item in the collection is the primary source. - /// - public static IObservable> Except(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// - /// - /// Dynamic overload: sources can be added or removed from the at runtime. The first source in the list acts as the primary. - /// - public static IObservable> Except(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// - /// - /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Except(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// - /// - /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Except(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// Automatically removes items from the list after the duration returned by . - /// Returns an observable of the items that were expired and removed. - /// - /// The type of the item. - /// The source list to apply time-based expiration to. - /// A function returning the time-to-live for each item. Return for items that should never expire. - /// An optional polling interval to batch expiry checks. If omitted, a separate timer is created for each unique expiry time. - /// The scheduler for scheduling expiry timers. Defaults to . - /// An observable that emits collections of items each time expired items are removed from the source list. - /// - /// - /// This operator acts directly on an , not on a changeset stream. It monitors items as they are added, - /// schedules their removal, and physically removes them from the source list when their time expires. - /// - /// - /// When is specified, all items due for removal are batched into a single removal at each polling tick, - /// which can improve performance when many items expire around the same time. - /// - /// Worth noting: The returned observable emits the expired items (not changesets). Subscribe to this observable to trigger the expiry mechanism; if not subscribed, no items will be removed. - /// - /// - /// - public static IObservable> ExpireAfter( - this ISourceList source, - Func timeSelector, - TimeSpan? pollingInterval = null, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ExpireAfter.Create( - source: source, - timeSelector: timeSelector, - pollingInterval: pollingInterval, - scheduler: scheduler); - - /// - /// Filters items from the source list changeset stream using a static predicate. - /// Only items satisfying are included downstream. - /// - /// The type of items in the list. - /// The source to filter. - /// A predicate that determines which items are included. Items returning appear downstream; items returning are excluded. - /// A list changeset stream containing only items that satisfy . - /// Thrown when or is . - /// - /// - /// Use this overload when you need only a single predicate function for the lifetime of the subscription; - /// unlike the dynamic-predicate and state-driven overloads, the predicate function itself never changes. - /// Note that this does not mean an item's inclusion is fixed: Refresh events can re-evaluate each item against the predicate - /// and promote a previously-excluded item to included (or vice versa). - /// Item ordering is preserved. - /// - /// - /// EventBehavior - /// AddThe predicate is evaluated. If the item passes, an Add is emitted at the calculated downstream index. Otherwise dropped. - /// AddRangeEach item in the range is evaluated. Matching items are emitted as an AddRange. - /// ReplaceThe predicate is re-evaluated. Four outcomes: both pass produces Replace; new passes but old didn't produces Add; old passed but new doesn't produces Remove; neither passes is dropped. - /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. - /// RemoveRangeIncluded items in the range are emitted as individual Remove changes. - /// RefreshThe predicate is re-evaluated. If the item now passes but previously did not, an Add is emitted. If it previously passed but no longer does, a Remove is emitted. If still passes, the Refresh is forwarded. If still fails, dropped. - /// ClearAll downstream items are cleared. - /// - /// Worth noting: Refresh events trigger re-evaluation, which can promote or demote items (turning a Refresh into an Add or Remove). Pair with for property-change-driven filtering. - /// - /// - /// - /// - /// - public static IObservable> Filter( - this IObservable> source, - Func predicate) - where T : notnull - => List.Internal.Filter.Static.Create( - source: source, - predicate: predicate, - suppressEmptyChangesets: true); - - /// - /// Filters items using a dynamically changing predicate. - /// When emits a new function, all items are re-evaluated. - /// - /// The type of the item. - /// The source to filter. - /// An that emits new predicate functions. Each emission triggers a full re-evaluation of all items. - /// The that controls re-filtering behavior when the predicate changes. - /// A list changeset stream containing only items that satisfy the most recent predicate. - /// - /// - /// Each time emits, every item is re-evaluated against the new predicate. - /// - /// - /// EventBehavior - /// AddThe current predicate is evaluated. If the item passes, an Add is emitted. Otherwise dropped. - /// AddRangeEach item is evaluated. Matching items are emitted as AddRange. - /// ReplaceRe-evaluated. Same four-outcome logic as the static overload (Replace, Add, Remove, or dropped). - /// RemoveIf the item was downstream, a Remove is emitted. Otherwise dropped. - /// RefreshRe-evaluated. If inclusion status changed, an Add or Remove is emitted. If unchanged, Refresh forwarded or dropped. - /// ClearAll downstream items are cleared. - /// Predicate changedAll items are re-evaluated against the new predicate. The output is shaped by . - /// OnCompletedIndependent completion of does not terminate the filter. - /// - /// Worth noting: No items are included until emits its first function. - /// - /// or is . - /// - /// - public static IObservable> Filter(this IObservable> source, IObservable> predicate, ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); - - return new List.Internal.Filter.Dynamic(source, predicate, filterPolicy).Run(); - } - - /// - /// Filters items using a predicate that receives external state. When emits a new state value, - /// all items are re-evaluated against using the updated state. - /// - /// The type of the item. - /// The type of state value required by . - /// The source to filter. - /// An stream of state values to be passed to . - /// A static predicate receiving the current state and an item, returning to include or to exclude. The function itself does not change; only the state value passed to it changes. - /// The that controls re-filtering behavior when the state changes. - /// When (default), empty changesets are suppressed. Set to to publish empty changesets (useful for monitoring loading status). - /// A list changeset stream containing only items satisfying with the current state. - /// , , or is . - /// - /// - /// The predicate cannot be invoked until the first state value is received. Until then, all items are treated as excluded. - /// Each subsequent state emission triggers a full re-evaluation of all items according to . - /// - /// - /// EventBehavior - /// Add/AddRangeEvaluated using current state. Matching items emitted as Add/AddRange. - /// ReplaceRe-evaluated. Same four-outcome logic as the static filter (Replace, Add, Remove, or dropped). - /// Remove/RemoveRangeIf the item was downstream, a Remove is emitted. - /// RefreshRe-evaluated against current state. Inclusion status may change. - /// ClearAll downstream items are cleared. - /// State changedAll items are re-evaluated with the new state value. The output is shaped by . - /// - /// - /// - /// - public static IObservable> Filter( - this IObservable> source, - IObservable predicateState, - Func predicate, - ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff, - bool suppressEmptyChangeSets = true) - where T : notnull - => List.Internal.Filter.WithPredicateState.Create( - source: source, - predicateState: predicateState, - predicate: predicate, - filterPolicy: filterPolicy, - suppressEmptyChangeSets: suppressEmptyChangeSets); - - /// - /// Filters each item using a per-item of that dynamically controls inclusion. - /// When an item's observable emits the item enters the result; when it emits the item is removed. - /// - /// The type of items in the list. - /// The source to filter by property value. - /// A function that returns an observable of for each item, controlling its inclusion. - /// An optional throttle duration applied to each per-item observable to reduce re-evaluation frequency. - /// The used when throttling. Defaults to the system default scheduler. - /// A list changeset stream containing only items whose per-item observable most recently emitted . - /// or is . - /// - /// - /// Each item in the source gets its own subscription to the observable returned by . - /// The item's inclusion is determined by the most recent boolean value emitted by that observable. - /// - /// - /// Event (source)Behavior - /// Add/AddRangeSubscribes to the per-item observable. Item is included when it first emits . - /// ReplaceOld subscription disposed, new subscription created for the replacement item. - /// Remove/RemoveRange/ClearSubscription disposed. If the item was downstream, a Remove is emitted. - /// RefreshForwarded if the item is currently included. - /// - /// - /// Event (per-item observable)Behavior - /// Emits If not already included, an Add is emitted downstream. - /// Emits If currently included, a Remove is emitted downstream. - /// - /// - /// - /// - /// - /// - public static IObservable> FilterOnObservable(this IObservable> source, Func> objectFilterObservable, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new FilterOnObservable(source, objectFilterObservable, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Filters items based on a property value, automatically re-evaluating when the specified property changes on any item. - /// - /// The type of the object. Must implement . - /// The type of the property. - /// The source to filter by property value. - /// selecting the property to monitor for changes. - /// A predicate evaluated against the item to determine inclusion. - /// An optional throttle duration for property change notifications. - /// The used when throttling. - /// A list changeset stream of items satisfying the predicate, re-evaluated on property changes. - /// - /// Deprecated. Use followed by instead. - /// - /// - /// - [Obsolete("Use AutoRefresh(), followed by Filter() instead")] - public static IObservable> FilterOnProperty(this IObservable> source, Expression> propertySelector, Func predicate, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); - - return new FilterOnProperty(source, propertySelector, predicate, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Flattens buffered changesets (e.g. from ) back into single changesets. - /// Empty buffers are dropped. - /// - /// The type of the item. - /// The of buffered changeset lists. - /// A list changeset stream with all buffered changes concatenated into single changesets. - /// - /// Use this after applying Observable.Buffer() to a changeset stream to re-merge the batched changesets into a single stream. - /// - /// - /// - public static IObservable> FlattenBufferResult(this IObservable>> source) - where T : notnull => source.Where(x => x.Count != 0).Select(updates => new ChangeSet(updates.SelectMany(u => u))); - - /// - /// Invokes once for every in each changeset. Range changes - /// (AddRange, RemoveRange, Clear) are delivered as a single ; they are not flattened into per-item changes. - /// The changeset is forwarded downstream unchanged. - /// - /// The type of items in the list. - /// The source to observe each change in. - /// The action invoked for each . - /// A continuation of the source changeset stream. - /// or is . - /// - /// This is a side-effect operator. It does not modify the changeset. If you need each individual item from range operations flattened out, use instead. - /// - /// EventBehavior - /// Add/Replace/Remove/Moved/RefreshCallback invoked with the (single-item change). Changeset forwarded. - /// AddRange/RemoveRange/ClearCallback invoked once with the containing the range (accessible via Range property). Changeset forwarded. - /// OnErrorIf the callback throws, the exception propagates as OnError. - /// - /// - /// - /// - /// - /// - public static IObservable> ForEachChange(this IObservable> source, Action> action) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - action.ThrowArgumentNullExceptionIfNull(nameof(action)); - - return source.Do(changes => changes.ForEach(action)); - } - - /// - /// Invokes for every individual in each changeset. - /// Range changes are flattened into individual item changes first, so the callback only receives Add, Replace, Remove, and Refresh. - /// - /// The type of items in the list. - /// The source to observe each item-level change in. - /// The action invoked for each individual item change. - /// A continuation of the source changeset stream. - /// or is . - /// - /// - /// Unlike , this operator flattens - /// AddRange, RemoveRange, and Clear into individual entries before invoking the callback. - /// - /// - /// - public static IObservable> ForEachItemChange(this IObservable> source, Action> action) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - action.ThrowArgumentNullExceptionIfNull(nameof(action)); - - return source.Do(changes => changes.Flatten().ForEach(action)); - } - - /// - /// Groups source items by the value returned by . Each group is an - /// containing an inner observable list of its members. - /// - /// The type of items in the list. - /// The type of the group key. - /// The source to group. - /// A function that returns the group key for each item. - /// An optional of that forces all items to be re-evaluated against when it fires. Useful for time-based groupings (e.g., "Last Hour", "Today"). - /// A list changeset stream of objects, each containing the items belonging to that group. - /// or is . - /// - /// - /// Groups are created lazily and removed when empty. Each group exposes an inner observable list that receives incremental updates. - /// - /// - /// EventBehavior - /// Add/AddRangeGroup key evaluated. Item added to its group. If the group is new, an Add of the group is emitted. - /// ReplaceGroup key re-evaluated. If the group changed, the item is removed from the old group and added to the new one. Empty old groups are removed. - /// Remove/RemoveRange/ClearItem removed from its group. Empty groups are removed from the result. - /// RefreshGroup key re-evaluated. If changed, the item moves between groups. - /// MovedNot handled by group logic. - /// Regrouper firesAll items re-evaluated. Items that changed group key are moved between groups. Empty groups removed, new groups added. - /// - /// - /// - /// - /// - public static IObservable>> GroupOn(this IObservable> source, Func groupSelector, IObservable? regrouper = null) - where TObject : notnull - where TGroup : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - groupSelector.ThrowArgumentNullExceptionIfNull(nameof(groupSelector)); - - return new GroupOn(source, groupSelector, regrouper).Run(); - } - - /// - /// Groups items by a property value, automatically re-grouping when the specified property changes on any item. - /// Each group contains an inner observable list. - /// - /// The type of the object. Must implement . - /// The type of the group key. - /// The source to group by property value. - /// selecting the property whose value determines the group key. - /// An optional throttle duration for property change notifications. - /// The used when throttling. - /// A list changeset stream of objects. - /// or is . - /// - /// - /// Convenience operator equivalent to .AutoRefresh(propertySelector).GroupOn(item => property). - /// Property changes trigger re-evaluation of the group key, potentially moving items between groups. - /// - /// - /// - /// - /// - public static IObservable>> GroupOnProperty(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TGroup : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - return new GroupOnProperty(source, propertySelector, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Groups items by a property value, automatically re-grouping when the specified property changes. - /// Each group emits immutable snapshots (not live observable lists). - /// - /// The type of the object. Must implement . - /// The type of the group key. - /// The source to group by property value with immutable snapshots. - /// selecting the property whose value determines the group key. - /// An optional throttle duration for property change notifications. - /// The used when throttling. - /// A list changeset stream of immutable group snapshots. - /// or is . - /// - /// - /// Combines - /// with . - /// Unlike , - /// this produces immutable snapshots per group rather than live inner observable lists. - /// - /// - /// - /// - public static IObservable>> GroupOnPropertyWithImmutableState(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TGroup : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - return new GroupOnPropertyWithImmutableState(source, propertySelector, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Groups source items by the value returned by . Each update produces immutable grouping snapshots - /// rather than live inner observable lists. - /// - /// The type of items in the list. - /// The type of the group key. - /// The source to group with immutable snapshots. - /// A function that returns the group key for each item. - /// An optional of that forces all items to be re-evaluated when it fires. - /// A list changeset stream of immutable snapshots. - /// or is . - /// - /// - /// Works like - /// but each affected group emits a new immutable snapshot on every change rather than updating a live inner list. - /// This is useful when consumers need thread-safe, point-in-time snapshots of each group. - /// - /// - /// - /// - public static IObservable>> GroupWithImmutableState(this IObservable> source, Func groupSelectorKey, IObservable? regrouper = null) - where TObject : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); - - return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); - } - - /// - /// Limits the source list to a maximum number of items using FIFO eviction. - /// When the list exceeds , the oldest items are removed. - /// Returns an observable of the items that were removed. - /// - /// The type of the item. - /// The source list to apply size limits to. - /// The maximum number of items allowed. Must be greater than zero. - /// The scheduler for scheduling size checks. Defaults to . - /// An observable that emits collections of items each time excess items are removed from the source list. - /// is . - /// is zero or negative. - /// - /// - /// This operator acts directly on an . It subscribes to the source's changes, - /// tracks insertion order using an internal Transform, and removes the oldest items when the size limit is exceeded. - /// - /// Worth noting: The returned observable emits the removed items (not changesets). Subscribe to this observable to activate the size-limiting mechanism. Removal is performed synchronously under a lock shared with the change tracking. - /// - /// - /// - public static IObservable> LimitSizeTo(this ISourceList source, int sizeLimit, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (sizeLimit <= 0) - { - throw new ArgumentException("sizeLimit cannot be zero", nameof(sizeLimit)); - } - - var locker = InternalEx.NewLock(); - var limiter = new LimitSizeTo(source, sizeLimit, scheduler ?? GlobalConfig.DefaultScheduler, locker); - - return limiter.Run().Synchronize(locker).Do(source.RemoveMany); - } - - /// - /// Subscribes to a per-item observable for each item in the source and merges all emissions into a single stream. - /// This is NOT a changeset operator: it returns a flat observable of values. - /// - /// The type of items in the source list. - /// The type of values emitted by per-item observables. - /// The source whose items each produce an observable. - /// A function that returns an observable for each source item. - /// An observable that emits values from all per-item observables, merged together. - /// or is . - /// - /// - /// Event (source)Subscription behavior - /// Add/AddRangeSubscribes to the per-item observable. Emissions are merged into the output. - /// ReplaceOld subscription disposed, new subscription created for the replacement item. - /// Remove/RemoveRange/ClearSubscription disposed. - /// Refresh/MovedNo effect on subscriptions. - /// OnCompleted (source)Completes only after the source and all active inner observables have completed. - /// - /// - /// - /// - /// - /// - public static IObservable MergeMany(this IObservable> source, Func> observableSelector) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeMany(source, observableSelector).Run(); - } - - /// - /// - /// Merges multiple list changeset streams from an observable-of-observables into a single unified changeset stream. - /// Unlike , list merging performs no key-based deduplication. - /// - /// The source of nested changeset observables. - /// An optional used by the merge tracker to compare items. - public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer? equalityComparer = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new MergeChangeSets(source, equalityComparer).Run(); - } - - /// - /// - /// Merges two list changeset streams into a single unified stream. - /// - /// The first to merge. - /// The second to merge with. - /// An optional used to compare items. - /// An optional for scheduling enumeration. - /// When (default), the result completes when all sources complete. - public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - other.ThrowArgumentNullExceptionIfNull(nameof(other)); - - return new[] { source, other }.MergeChangeSets(equalityComparer, scheduler, completable); - } - - /// - /// - /// Merges the source list changeset stream with additional changeset streams into a single unified stream. - /// - /// The primary source to merge. - /// The additional of list changeset streams to merge with. - /// An optional used to compare items. - /// An optional for scheduling enumeration. - /// When (default), the result completes when all sources complete. - public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, scheduler, completable); - } - - /// - /// Merges a collection of list changeset streams into a single unified changeset stream. - /// This is the canonical list MergeChangeSets overload: other overloads accepting , , or pair/params variants ultimately produce equivalent behavior. - /// - /// The type of items in the list. - /// The collection of list changeset streams to merge. - /// An optional used by the merge tracker to compare items. Defaults to when . - /// An optional for scheduling enumeration. - /// When (default), the result completes when all sources complete. - /// A single list changeset stream containing all changes from all sources. - /// is . - /// - /// - /// All changes from inner streams are forwarded to the output. There is no key-based deduplication (unlike ): if the same item appears in multiple inner streams, it will appear multiple times in the merged output. - /// - /// - /// EventBehavior - /// Add/AddRangeForwarded to the merged output. - /// ReplaceThe old value is replaced by the new value in the merged output. If the old value is not found (by ), the new value is added instead. - /// Remove/RemoveRange/ClearForwarded to the merged output. - /// RefreshForwarded to the merged output. - /// MovedIgnored. - /// - /// - /// - /// - /// - /// - public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new MergeChangeSets(source, equalityComparer, completable, scheduler).Run(); - } - - /// - /// - /// Merges list changeset streams from an into a single stream. Sources can be added or removed dynamically. - /// - public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().MergeChangeSets(equalityComparer); - } - - /// - /// - /// Merges list changeset streams from a list-of-list-changeset-observables into a single stream. - /// Each inner list changeset observable in the source list is merged, and parent item removal triggers child cleanup. - /// - public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeManyChangeSets(static src => src, equalityComparer); - } - - /// - /// Merges cache changeset streams from an into a single cache changeset stream. - /// Uses to resolve conflicts when the same key appears in multiple child streams. - /// - /// The type of items in the list. - /// The type of the object key. - /// The of cache changeset observables. - /// to resolve which value wins when the same key appears in multiple sources. - /// A single cache changeset stream with key-based deduplication. - /// is . - /// - /// Sources can be added or removed dynamically from the observable list. Parent item removal triggers cleanup of all child items from that source. - /// - /// EventBehavior - /// Add (child)If the destination key is new, an Add is emitted. If another source already contributed a child with the same key, resolves the conflict (lowest-ordered value wins). The losing value is tracked internally but not emitted. - /// Update (child)If this source currently owns the destination key downstream, an Update is emitted. Otherwise re-evaluates all sources; a different source's value may win, producing an Update to that value instead. - /// Remove (child)If this source's value was the one published downstream for that destination key, the operator scans other sources for the same key. If found, an Update is emitted with the replacement (per ). Otherwise a Remove is emitted. - /// Refresh (child)If the child item is the one currently published downstream, the Refresh is forwarded. Otherwise re-evaluates all sources; if a different value now wins, an Update is emitted instead. - /// Source list AddSubscribes to the new child changeset stream and merges its keys into the output. - /// Source list RemoveDisposes that source's subscription. All keys it contributed are removed. For keys also contributed by other sources, the next-best value (per ) is promoted as an Update, not an Add. - /// - /// - /// - /// - public static IObservable> MergeChangeSets(this IObservableList>> source, IComparer comparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().MergeChangeSets(comparer); - } - - /// - /// - /// Merges cache changeset streams from an into a single cache changeset stream, with optional equality and ordering comparers. - /// - /// The of cache changeset observables. - /// An optional to determine if two elements are the same. - /// An optional to resolve conflicts when the same key appears in multiple sources. - public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().MergeChangeSets(equalityComparer, comparer); - } - - /// - /// - /// Merges cache changeset streams from a list changeset of cache changeset observables, using a comparer for conflict resolution. - /// - /// The source whose items are cache changeset observables. - /// to resolve which value wins when the same key appears in multiple sources. - public static IObservable> MergeChangeSets(this IObservable>>> source, IComparer comparer) - where TObject : notnull - where TKey : notnull - { - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.MergeChangeSets(comparer); - } - - /// - /// - /// Merges cache changeset streams from a list changeset of cache changeset observables, with optional equality and ordering comparers. - /// - /// The source whose items are cache changeset observables. - /// An optional to determine if two elements are the same. - /// An optional to resolve conflicts when the same key appears in multiple sources. - public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeManyChangeSets(static src => src, equalityComparer, comparer); - } - - /// - /// Transforms each source item into a child list changeset stream using , - /// then merges all child streams into a single flat list changeset stream. Parent item removal cleans up all associated children. - /// - /// The type of items in the source list. - /// The type of items in the child changeset streams. - /// The source whose items each produce a child changeset stream. - /// A function that returns a child list changeset stream for each source item. - /// An optional used to compare child items. - /// A single list changeset stream containing all items from all child streams. - /// or is . - /// - /// - /// Internally subscribes to each child stream when a source item is added and disposes the subscription when it is removed. - /// All child items from a removed parent are removed from the merged output. - /// - /// - /// Event (source)Behavior - /// Add/AddRangeSubscribes to the child stream. Child emissions are merged into the output. - /// ReplaceOld child subscription disposed (and its items removed from output). New child subscription created. - /// Remove/RemoveRange/ClearChild subscription disposed. All child items from that parent are removed. - /// - /// - /// - /// - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) - 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, equalityComparer).Run(); - } - - /// - /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. - /// Uses to resolve key conflicts when the same key appears in multiple child streams. - /// - /// The type of items in the source list. - /// The type of items in the child cache changeset streams. - /// The type of the key in the child cache changesets. - /// The source whose items each produce a child changeset stream. - /// A function that returns a child cache changeset stream for each source item. - /// to resolve which value wins when the same key appears from multiple children. - /// A single cache changeset stream with key-based deduplication. - /// , , or is . - /// - /// - /// Delegates to with a equality comparer. - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) - where TObject : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.MergeManyChangeSets(observableSelector, equalityComparer: null, comparer: comparer); - } - - /// - /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. - /// This is the primary list-to-cache MergeManyChangeSets overload. - /// - /// The type of items in the source list. - /// The type of items in the child cache changeset streams. - /// The type of the key in the child cache changesets. - /// The source whose items each produce a child changeset stream. - /// A function that returns a child cache changeset stream for each source item. - /// An optional to determine if two elements are the same. - /// An optional to resolve conflicts when the same key appears from multiple children. - /// A single cache changeset stream with key-based deduplication. - /// or is . - /// - /// - /// Each source item produces a keyed child stream via . All child items are tracked by key. - /// When a parent item is removed, all its child items are removed from the merged output. - /// When the same key appears from multiple children, determines which value wins. - /// - /// - /// Event (source)Behavior - /// Add/AddRangeSubscribes to the child cache stream. Child key/value pairs are merged into the output cache. - /// ReplaceOld child subscription disposed (and its keys removed from output). New child subscription created. - /// Remove/RemoveRange/ClearChild subscription disposed. All keys originating from that child are removed from the output. - /// Moved/RefreshIgnored; this operator emits a cache changeset and source ordering/refresh does not affect key membership. - /// - /// - /// Error and completion: - /// - /// - /// EventBehavior - /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. - /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. - /// - /// - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); - } - - /// - /// Suppresses empty changesets from the stream. Only changesets with at least one change are forwarded. - /// - /// The type of the item. - /// The source to suppress empty changesets. - /// A list changeset stream with empty changesets filtered out. - /// is . - /// - /// - public static IObservable> NotEmpty(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Where(s => s.Count != 0); - } - - /// - /// Invokes for every item added to the source list stream. - /// Triggers on , , and the new item of . - /// - /// The type of items in the list. - /// The source to observe item additions in. - /// The action to invoke for each added item. - /// A continuation of the source changeset stream, with the side effect applied before forwarding. - /// or is . - /// - /// The action fires before the changeset is forwarded downstream. - /// - /// EventBehavior - /// AddCallback invoked with the added item. Changeset forwarded. - /// AddRangeCallback invoked for each item in the range. Changeset forwarded. - /// ReplaceCallback invoked for the new (replacement) item. Changeset forwarded. - /// Remove/RemoveRange/ClearNo callback. Changeset forwarded. - /// Moved/RefreshNo callback. Changeset forwarded. - /// OnErrorIf the callback throws, the exception propagates as OnError. - /// - /// - /// - /// - /// - /// - public static IObservable> OnItemAdded( - this IObservable> source, - Action addAction) - where T : notnull - => List.Internal.OnItemAdded.Create( - source: source, - addAction: addAction); - - /// - /// Invokes for every item with a change in the source stream. - /// - /// The type of items in the list. - /// The source to observe item refresh events in. - /// The action to invoke for each refreshed item. - /// A continuation of the source changeset stream, with the side effect applied before forwarding. - /// or is . - /// - /// - /// - /// - public static IObservable> OnItemRefreshed( - this IObservable> source, - Action refreshAction) - where T : notnull - => List.Internal.OnItemRefreshed.Create( - source: source, - refreshAction: refreshAction); - - /// - /// Invokes for every item removed from the source list stream. - /// Triggers on , , , and the old item of . - /// - /// The type of items in the list. - /// The source to observe item removals in. - /// The action to invoke for each removed item. - /// When (default), is also invoked for all remaining tracked items upon stream disposal, completion, or error. - /// A continuation of the source changeset stream, with the side effect applied before forwarding. - /// or is . - /// - /// - /// When is , the operator tracks all items that have been added but not yet removed, - /// and fires for each of them during finalization. This is useful for resource cleanup patterns. - /// - /// - /// EventBehavior - /// Add/AddRangeTracked internally (when is ). No callback invoked. Changeset forwarded. - /// ReplaceCallback invoked for the previous (replaced) item. New item tracked. Changeset forwarded. - /// RemoveCallback invoked for the removed item. Changeset forwarded. - /// RemoveRange/ClearCallback invoked for each removed item. Changeset forwarded. - /// Moved/RefreshNo callback. Changeset forwarded. - /// OnErrorIf is , callback is invoked for all tracked items before the error propagates. - /// OnCompletedIf is , callback is invoked for all tracked items before completion propagates. - /// - /// Worth noting: When is (the default), disposing the subscription also invokes the callback for every item still in the list, not just items that were explicitly removed during the subscription. Exceptions in are not caught. - /// - /// - /// - /// - /// - public static IObservable> OnItemRemoved( - this IObservable> source, - Action removeAction, - bool invokeOnUnsubscribe = true) - where T : notnull - => List.Internal.OnItemRemoved.Create( - source: source, - removeAction: removeAction, - invokeOnUnsubscribe: invokeOnUnsubscribe); - - /// - /// - /// Applies a logical OR (union) between a pre-built collection of list changeset sources. Items present in any source are included. - /// - /// - public static IObservable> Or(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// Applies a logical OR (union) between the source and other list changeset streams. - /// Items present in any of the sources are included in the result, using reference-counted equality. - /// - /// The type of the item. - /// The primary source to union. - /// The other changeset streams to combine with. - /// A list changeset stream containing items that exist in at least one source. - /// is . - /// - /// - /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it first appears in any source and removed when it no longer exists in any source. - /// Moved changes are ignored by the set logic. - /// - /// - /// EventBehavior - /// Add/AddRange (any source)If the item is new to the result, an Add is emitted. Otherwise the reference count is incremented. - /// Remove/RemoveRange/Clear (any source)Reference count decremented. If count reaches zero, a Remove is emitted. - /// ReplaceOld item reference count decremented, new item reference count incremented. Add/Remove emitted as needed. - /// RefreshForwarded if the item is in the result set. - /// MovedIgnored. - /// - /// - /// - /// - /// - /// - public static IObservable> Or(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.Or, others); - } - - /// - /// - /// Dynamic OR: sources can be added or removed from the at runtime. - /// - public static IObservable> Or(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// - /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Or(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// - /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Or(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// Applies page-based windowing to the source list. Only items within the current page (determined by page number and page size from ) are included downstream. - /// - /// The type of the item. - /// The source to page. - /// An observable of controlling which page to display (page number and page size). - /// An stream containing only items within the current page window. - /// or is . - /// - /// - /// Maintains the full source list internally and calculates the page window on each change or page request. - /// Items entering the page window produce Add; items leaving produce Remove. A new page request triggers - /// a full recalculation of the page contents. - /// - /// Worth noting: Duplicate items are removed from the result via Distinct() using the default equality comparer for , regardless of source order. The source should ideally be sorted before paging, since list order determines which items fall within each page window. - /// - /// - /// - /// - public static IObservable> Page(this IObservable> source, IObservable requests) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); - - return new Pager(source, requests).Run(); - } - - /// - /// Subscribes to the source changeset stream and pipes all changes into the . - /// - /// The type of the object. - /// The source to pipe into a target list. - /// The destination to receive all changes. - /// An representing the subscription. Dispose to stop piping changes. - /// or is . - /// - /// Each changeset is applied to the destination using Clone() inside an Edit() call, producing a single batch update per changeset. - /// - /// - /// - /// - public static IDisposable PopulateInto(this IObservable> source, ISourceList destination) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); - } - - /// - /// Emits a projected value from the current list snapshot after every changeset. - /// The receives an representing the current state. - /// - /// The type of items in the list. - /// The type of the projected result. - /// The source to project on each change. - /// A function projecting the current list snapshot to a result value. - /// An observable emitting the projected value after each changeset. - /// or is . - /// - /// Delegates to and applies via Select. - /// - /// - /// - /// - public static IObservable QueryWhenChanged(this IObservable> source, Func, TDestination> resultSelector) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return source.QueryWhenChanged().Select(resultSelector); - } - - /// - /// Emits an snapshot of the current list state after every changeset. - /// Maintains an internal list updated by cloning each changeset. - /// - /// The type of items in the list. - /// The source to project on each change. - /// An observable emitting the full list snapshot as after each change. - /// is . - /// - /// This is a non-changeset operator. It emits the entire collection state on each change, not incremental diffs. - /// Worth noting: A new snapshot is emitted on every changeset, which can be chatty. The collection is rebuilt by cloning each changeset into an internal list. For sorted output, use . - /// - /// - /// - /// - public static IObservable> QueryWhenChanged(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new QueryWhenChanged(source).Run(); - } - - /// - /// Reference-counted materialization of the source changeset stream into an . - /// The shared list is created on the first subscriber and disposed when the last subscriber unsubscribes. - /// - /// The type of the item. - /// The source to share via reference counting. - /// A list changeset stream backed by a shared, reference-counted . - /// is . - /// - /// Equivalent to Publish().RefCount() for changeset streams. The underlying list is created lazily on first subscription. - /// - /// - public static IObservable> RefCount(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new RefCount(source).Run(); - } - - /// - /// Strips index information from all changes in the stream. - /// - /// The type of the object. - /// The source to strip index information. - /// A list changeset stream with all index values removed from changes. - /// is . - /// - /// Removes index positions from every change in each changeset. This is useful when downstream operators do not require or support index-based operations. - /// - /// - public static IObservable> RemoveIndex(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select(changes => new ChangeSet(changes.YieldWithoutIndex())); - } - - /// - /// Reverses the order of items in the changeset stream by transforming all indices: new_index = length - old_index - 1. - /// - /// The type of the item. - /// The source to reverse. - /// A list changeset stream with all index positions reversed. - /// is . - /// - /// This is a pure index transformation. The items themselves are unchanged; only their positional indices are inverted. - /// - /// - public static IObservable> Reverse(this IObservable> source) - where T : notnull - { - var reverser = new Reverser(); - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select(changes => new ChangeSet(reverser.Reverse(changes))); - } - - /// - /// Skips the initial changeset (the snapshot emitted on subscription) and forwards all subsequent changesets. - /// Internally defers until loaded, then skips the first emission. - /// - /// The type of the object. - /// The source to skip the initial changeset. - /// A list changeset stream that omits the initial snapshot. - /// is . - /// - /// - /// Warning: This operator assumes the initial changeset is empty. If the source emits a non-empty - /// initial snapshot, those items are silently dropped while downstream consumers remain unaware of them. - /// Any later Refresh, Replace, Remove, or Moved change targeting one of those - /// dropped items will throw because the downstream collection has no record of them. Only use this against - /// a source you know starts empty (for example, a that has not yet been populated). - /// - /// - /// - /// - public static IObservable> SkipInitial(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.DeferUntilLoaded().Skip(1); - } - - /// - /// Sorts the list using the specified comparer, maintaining a sorted output that incrementally updates as items change. - /// - /// The type of the item. - /// The source to sort. - /// The used for sorting. - /// The for improved performance when sorted values are immutable. - /// An optional of that forces a full re-sort when it fires. Required when sorted property values are mutable. - /// An optional of that replaces the comparer, triggering a full re-sort. - /// When the number of changes exceeds this threshold, a full reset is performed instead of incremental updates. Default is 50. - /// A list changeset stream with items in sorted order. - /// or is . - /// - /// - /// Maintains an internal sorted list. Each incoming change is applied incrementally: adds are inserted at the correct sorted position, - /// removes are removed by index, and refreshes re-evaluate position (emitting Moved if changed). - /// - /// - /// EventBehavior - /// Add/AddRangeInserted at the correct sorted position. May trigger a full reset if the count exceeds . - /// ReplaceOld item removed, new item inserted at sorted position. - /// Remove/RemoveRange/ClearRemoved from sorted list. - /// RefreshSort position re-evaluated. If position changed, a Moved is emitted. - /// Comparer changedFull re-sort of all items. - /// Re-sort signalFull re-sort using the current comparer. - /// - /// Worth noting: is faster but requires that the values being sorted on never mutate. If they do, use the signal or . - /// - /// - /// - /// - /// - public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptions options = SortOptions.None, IObservable? resort = null, IObservable>? comparerChanged = null, int resetThreshold = 50) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new Sort(source, comparer, options, resort, comparerChanged, resetThreshold).Run(); - } - - /// - /// - /// Sorts the list using an observable comparer. The initial comparer is taken from the first emission; subsequent emissions trigger a full re-sort. - /// - /// - /// Until emits its first comparer, items are sorted using . Downstream still receives changesets immediately; the initial ordering is whatever produces, then a full re-sort happens once the first comparer arrives. - /// - /// The source to sort. - /// An of that emits comparers. The first emission provides the initial sort order; subsequent emissions trigger re-sorts. - /// for controlling sort behavior. - /// An optional of to force a re-sort with the current comparer. - /// The threshold for triggering a full reset instead of incremental updates. - public static IObservable> Sort(this IObservable> source, IObservable> comparerChanged, SortOptions options = SortOptions.None, IObservable? resort = null, int resetThreshold = 50) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparerChanged.ThrowArgumentNullExceptionIfNull(nameof(comparerChanged)); - - return new Sort(source, null, options, resort, comparerChanged, resetThreshold).Run(); - } - - /// - /// Prepends an empty changeset to the source stream. Useful for initializing downstream consumers that expect an initial emission. - /// - /// The type of item. - /// The source to prepend an empty changeset to. - /// A list changeset stream that begins with an empty changeset. - /// - /// - /// - public static IObservable> StartWithEmpty(this IObservable> source) - where T : notnull => source.StartWith(ChangeSet.Empty); - - /// - /// Creates an subscription for each item via when it is added. - /// The subscription is disposed when the item is removed or replaced. All subscriptions are disposed when the stream terminates. - /// The changeset is forwarded downstream unmodified. - /// - /// The type of the object. - /// The source to create a subscription for each item in. - /// A function that creates an for each item. - /// A continuation of the source changeset stream with per-item subscriptions managed as a side effect. - /// or is . - /// - /// - /// EventBehavior - /// Add/AddRangeSubscription created for each item via the factory. Changeset forwarded. - /// ReplaceOld item's subscription disposed, new subscription created. Changeset forwarded. - /// Remove/RemoveRange/ClearSubscriptions for removed items are disposed. Changeset forwarded. - /// Moved/RefreshForwarded. No subscription changes. - /// OnError/OnCompleted/DisposalAll active subscriptions are disposed. - /// - /// - /// - /// - /// - /// - public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); - - return new SubscribeMany(source, subscriptionFactory).Run(); - } - - /// - /// Suppresses all changes from the stream. All other change reasons pass through. - /// - /// The type of the object. - /// The source to strip refresh events. - /// A list changeset stream with Refresh changes removed. - /// - /// - public static IObservable> SuppressRefresh(this IObservable> source) - where T : notnull => source.WhereReasonsAreNot(ListChangeReason.Refresh); - - /// - /// Subscribes to the latest inner , switching to each new source and clearing the result when switching. - /// This is the changeset-aware equivalent of Rx's , which cannot be applied directly to changeset streams. - /// - /// The type of the object. - /// An observable that emits instances. Each emission triggers a switch to the new list. - /// A list changeset stream reflecting the most recently received inner list. - /// is . - /// - /// Convenience overload that calls Connect() on each inner list, then delegates to . - /// - /// - public static IObservable> Switch(this IObservable> sources) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Select(cache => cache.Connect()).Switch(); - } - - /// - /// Subscribes to the latest inner changeset stream, switching to each new source and clearing the destination when switching. - /// Previous subscriptions are disposed and the result set is emptied before subscribing to the new inner stream. - /// - /// The type of the object. - /// An of changeset streams. The operator subscribes to the latest inner stream. - /// A list changeset stream reflecting the most recently received inner changeset stream. - /// is . - /// - /// - /// On each new inner stream, the operator clears the destination, disposes the previous subscription, and subscribes to the new stream. - /// This is the changeset-aware equivalent of Rx's Switch(). - /// - /// - /// - public static IObservable> Switch(this IObservable>> sources) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new Switch(sources).Run(); - } - - /// - /// Emits the full collection as an after every changeset. Equivalent to QueryWhenChanged(items => items). - /// - /// The type of items in the list. - /// The source to materialize into a collection on each change. - /// An observable emitting the full collection snapshot after each change. - /// - /// - /// - public static IObservable> ToCollection(this IObservable> source) - where TObject : notnull => source.QueryWhenChanged(items => items); - - /// - /// Bridges an into the DynamicData world by converting each emitted item into a list changeset. - /// Each emission becomes an Add operation in the resulting changeset stream. - /// - /// The type of the object. - /// The source to convert into a changeset stream. - /// An optional for time-based operations (expiry, size limiting). - /// A list changeset stream where each source emission is an Add. - /// is . - /// - /// - /// This is the primary bridge from standard Rx into DynamicData's list changeset model. Each item emitted by - /// is added to an internal list and an Add changeset is emitted. The list grows unboundedly unless size or time limits - /// are specified via other overloads. - /// - /// Worth noting: Source completion and errors are propagated. The internal list is disposed on unsubscribe. - /// - /// - /// - public static IObservable> ToObservableChangeSet( - this IObservable source, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an into a list changeset stream with per-item time-based expiry. - /// Expired items are automatically removed. - /// - /// The source to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// An optional for expiry timers. - public static IObservable> ToObservableChangeSet( - this IObservable source, - Func expireAfter, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an into a list changeset stream with FIFO size limiting. - /// When the list exceeds , the oldest items are removed. - /// - /// The source to convert into a changeset stream. - /// The maximum list size. Supply -1 to disable size limiting. - /// An optional for scheduling removals. - public static IObservable> ToObservableChangeSet( - this IObservable source, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// - /// Bridges an into a list changeset stream with both time-based expiry and FIFO size limiting. - /// - /// The source to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// The maximum list size. Supply -1 to disable size limiting. - /// An optional for expiry timers and size-limit checks. - public static IObservable> ToObservableChangeSet( - this IObservable source, - Func? expireAfter, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream. - /// Each emitted batch becomes an AddRange. - /// - /// The source of to convert into a changeset stream. - /// An optional for time-based operations. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream with FIFO size limiting. - /// - /// The source of to convert into a changeset stream. - /// The maximum list size. Oldest items are removed when the limit is exceeded. - /// An optional for scheduling removals. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream with time-based expiry. - /// - /// The source of to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// An optional for expiry timers. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - Func expireAfter, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream with both time-based expiry and FIFO size limiting. - /// - /// The source of to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// The maximum list size. Oldest items removed when exceeded. - /// An optional for expiry timers and size-limit checks. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - Func? expireAfter, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// Takes the first items from the source list. Implemented as Virtualise with a fixed window starting at index 0. - /// - /// The type of the item. - /// The source to take the top items. - /// The maximum number of items to include. Must be greater than zero. - /// A virtual changeset stream containing at most items from the beginning of the source. - /// is . - /// is zero or negative. - /// - /// The source should ideally be sorted before applying Top, since list order determines which items appear. - /// - /// - /// - /// - public static IObservable> Top(this IObservable> source, int numberOfItems) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (numberOfItems <= 0) - { - throw new ArgumentOutOfRangeException(nameof(numberOfItems), "Number of items should be greater than zero"); - } - - return source.Virtualise(Observable.Return(new VirtualRequest(0, numberOfItems))); - } - - /// - /// Emits a sorted after every changeset, sorted by the value returned by . - /// - /// The type of items in the list. - /// The type of the sort key. - /// The source to materialize into a sorted collection on each change. - /// A function extracting the sort key from each item. - /// The sort direction. Defaults to ascending. - /// An observable emitting a sorted collection snapshot after each change. - /// - /// - /// - /// - public static IObservable> ToSortedCollection(this IObservable> source, Func sort, SortDirection sortOrder = SortDirection.Ascending) - where TObject : notnull => source.QueryWhenChanged(query => sortOrder == SortDirection.Ascending ? new ReadOnlyCollectionLight(query.OrderBy(sort)) : new ReadOnlyCollectionLight(query.OrderByDescending(sort))); - - /// - /// Emits a sorted after every changeset, sorted using the specified . - /// - /// The type of items in the list. - /// The source to materialize into a sorted collection on each change. - /// The used for sorting. - /// An observable emitting a sorted collection snapshot after each change. - /// - /// - public static IObservable> ToSortedCollection(this IObservable> source, IComparer comparer) - where TObject : notnull => source.QueryWhenChanged( - query => - { - var items = query.AsList(); - items.Sort(comparer); - return new ReadOnlyCollectionLight(items); - }); - - /// - /// Projects each item to a new form using a synchronous transform function. - /// - /// The type of the source items. - /// The type of the destination items. - /// The source to transform. - /// The transform function applied to each item. - /// When , Refresh events re-invoke the factory and emit an update. When (the default), Refresh is forwarded without re-transforming. - /// A list changeset stream of transformed items. - /// - /// - /// Maintains an internal list of transformed items. Each source changeset is - /// processed and a corresponding output changeset is produced with the transformed items. - /// - /// - /// EventBehavior - /// AddThe factory is called and an Add is emitted at the same index. - /// AddRangeThe factory is called for each item. An AddRange is emitted at the same start index. - /// ReplaceThe factory is called for the new item. A Replace is emitted at the same index. The previous transformed value is available to overloads that accept . - /// RemoveA Remove is emitted (no factory call). - /// RemoveRangeA RemoveRange is emitted. - /// MovedA Moved is emitted with updated indices (no factory call). Throws if the source change has no index information. - /// RefreshIf is (default), the Refresh is forwarded without re-transforming. If , the factory is re-invoked and the result replaces the current value. - /// ClearA Clear is emitted and the internal list is emptied. - /// OnErrorIf the factory throws, the exception propagates as OnError. - /// - /// Worth noting: By default, Refresh does NOT re-transform the item (it just forwards the signal). Set to if you need the factory re-invoked on Refresh. Add operations with out-of-bounds indices silently append to the end. - /// - /// or is . - /// - /// - /// - /// - public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((t, _, _) => transformFactory(t), transformOnRefresh); - } - - /// - /// - /// Projects each item using a transform function that also receives the item's index. - /// - public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((t, _, idx) => transformFactory(t, idx), transformOnRefresh); - } - - /// - /// - /// Projects each item using a transform function that also receives the previously transformed value (if any). - /// Type arguments must be specified explicitly as type inference fails for this overload. - /// - public static IObservable> Transform(this IObservable> source, Func, TDestination> transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((t, previous, _) => transformFactory(t, previous), transformOnRefresh); - } - - /// - /// - /// Projects each item using a transform function that receives the source item, the previously transformed value, and the index. - /// Type arguments must be specified explicitly as type inference fails for this overload. - /// - public static IObservable> Transform(this IObservable> source, Func, int, TDestination> transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new Transformer(source, transformFactory, transformOnRefresh).Run(); - } - - /// - /// Projects each item to a new form using an async transform function. Behaves like but the factory returns a . - /// - /// The type of the source items. - /// The type of the destination items. - /// The source to transform asynchronously. - /// An async function that transforms each source item. - /// When , Refresh events re-invoke the factory. - /// A list changeset stream of asynchronously transformed items. - /// or is . - /// - /// Change handling is identical to the synchronous except the factory is awaited. Operations are serialized per changeset via a semaphore. - /// - /// EventBehavior - /// Add/AddRangeThe async factory is awaited for each item. An Add/AddRange is emitted with the transformed results. - /// ReplaceThe async factory is awaited for the new item. A Replace is emitted. - /// Remove/RemoveRangeEmitted without invoking the factory. - /// MovedEmitted with updated indices (no factory call). - /// RefreshIf is (default), forwarded without re-transforming. If , the factory is re-awaited. - /// ClearEmitted and internal list cleared. - /// OnErrorIf the async factory throws, the exception propagates as OnError. - /// OnCompletedForwarded after the last changeset is processed. - /// - /// Worth noting: All async transforms within a single changeset are serialized (not parallel). Each changeset is fully processed before the next begins. By default, Refresh does NOT re-transform. - /// - /// - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((t, _, _) => transformFactory(t), transformOnRefresh); - } - - /// - /// - /// Async transform overload receiving the source item and its index. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((t, _, i) => transformFactory(t, i), transformOnRefresh); - } - - /// - /// - /// Async transform overload receiving the source item and the previously transformed value. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func, Task> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((t, d, _) => transformFactory(t, d), transformOnRefresh); - } - - /// - /// - /// Async transform overload receiving the source item, previously transformed value, and index. This is the terminal overload that all other TransformAsync overloads delegate to. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func, int, Task> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new TransformAsync(source, transformFactory, transformOnRefresh).Run(); - } - - /// - /// Flattens each source item into multiple destination items using . Each source item produces zero or more children, - /// all of which are merged into a single flat list changeset stream. - /// - /// The type of the destination items. - /// The type of the source items. - /// The source to expand each item into multiple children. - /// A function that returns the child items for each source item. - /// An optional used during Replace to determine which child items changed between old and new parent values. - /// A list changeset stream of all child items from all source items. - /// or is . - /// - /// - /// EventBehavior - /// Add/AddRangeChildren expanded and added to the output. - /// ReplaceOld children diffed against new children (using ). Removed, added, or kept as appropriate. - /// Remove/RemoveRange/ClearAll children of the removed parents are removed from the output. - /// RefreshChildren re-expanded and diffed. - /// - /// - /// - /// - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - - return new TransformMany(source, manySelector, equalityComparer).Run(); - } - - /// - /// - /// Flattens each source item into children from an . The collection is observed for subsequent changes. - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); - - /// - /// - /// Flattens each source item into children from a . The collection is observed for subsequent changes. - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); - - /// - /// - /// Flattens each source item into children from an . The inner list is observed for subsequent changes. - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); - - /// - /// Applies a sliding window to the source list using start index and size from . - /// Only items within the window are included downstream. - /// - /// The type of the item. - /// The source to virtualize. - /// An observable of specifying the start index and size of the window. - /// An stream containing only items within the current virtual window. - /// or is . - /// - /// - /// Like but uses absolute start index and size instead of page number and page size. - /// Internally maintains the full source list and recalculates the window on each change or request. - /// - /// - /// - /// - public static IObservable> Virtualise(this IObservable> source, IObservable requests) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); - - return new Virtualiser(source, requests).Run(); - } - - /// - /// Watches all items in the source list and emits the item when any of its properties change. - /// Requires to implement . - /// This is NOT a changeset operator: it returns a flat . - /// - /// The type of the object. Must implement . - /// The source to observe property changes on items in. - /// An optional list of property names to monitor. If empty, all property changes are observed. - /// An observable emitting the item whenever any monitored property changes. - /// is . - /// - /// Implemented via . Subscriptions are managed per item: created on add, disposed on remove. - /// - /// - /// - /// - /// - public static IObservable WhenAnyPropertyChanged(this IObservable> source, params string[] propertiesToMonitor) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeMany(t => t.WhenAnyPropertyChanged(propertiesToMonitor)); - } - - /// - /// Watches a specific property on all items in the source list and emits a (item + value pair) when it changes. - /// Requires to implement . - /// This is NOT a changeset operator: it returns a flat . - /// - /// The type of item. Must implement . - /// The type of the property value. - /// The source to observe a specific property on items in. - /// An expression selecting the property to observe. - /// When (default), the current value is emitted immediately upon subscribing to each item. - /// An observable emitting whenever the property changes on any tracked item. - /// or is . - /// - /// Implemented via . - /// - /// - /// - /// - public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - var factory = propertyAccessor.GetFactory(); - return source.MergeMany(t => factory(t, notifyOnInitialValue)); - } - - /// - /// Watches a specific property on all items and emits just the property value (without the sender) when it changes. - /// Requires to implement . - /// This is NOT a changeset operator: it returns a flat . - /// - /// The type of item. Must implement . - /// The type of the property value. - /// The source to observe a specific property value on items in. - /// An expression selecting the property to observe. - /// When (default), the current value is emitted immediately upon subscribing to each item. - /// An observable emitting the property value whenever it changes on any tracked item. - /// or is . - /// - /// - /// - public static IObservable WhenValueChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - var factory = propertyAccessor.GetFactory(); - return source.MergeMany(t => factory(t, notifyOnInitialValue).Select(pv => pv.Value)); - } - - /// - /// Filters the changeset stream to include only changes with the specified values. - /// Index information is stripped from the output because removing some changes invalidates the original index positions. - /// - /// The type of the item. - /// The source to filter by change reason. - /// The change reasons to include. Must specify at least one. - /// A list changeset stream containing only changes with the specified reasons. - /// is . - /// is empty. - /// - /// Filters individual changes within each changeset. If filtering removes all changes from a changeset, the empty changeset is suppressed via . - /// Worth noting: Filtering out Remove changes can cause downstream operators to accumulate items indefinitely (memory leak). Index information is stripped because removing some changes invalidates the original index positions. - /// - /// - /// - /// - public static IObservable> WhereReasonsAre(this IObservable> source, params ListChangeReason[] reasons) - where T : notnull - { - reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); - - if (reasons.Length == 0) - { - throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); - } - - var matches = new HashSet(reasons); - return source.Select( - changes => - { - var filtered = changes.Where(change => matches.Contains(change.Reason)).YieldWithoutIndex(); - return new ChangeSet(filtered); - }).NotEmpty(); - } - - /// - /// Filters the changeset stream to exclude changes with the specified values. - /// Index information is stripped from the output because removing some changes invalidates the original index positions. - /// The exception is when only is excluded, since removing Refresh does not affect index calculations. - /// - /// The type of the item. - /// The source to filter by excluding change reasons. - /// The change reasons to exclude. Must specify at least one. - /// A list changeset stream with the specified change reasons removed. - /// is . - /// is empty. - /// - /// - /// Empty changesets (after filtering) are automatically suppressed. When only is excluded, - /// indices are preserved, since removing Refresh does not affect index calculations. - /// - /// - /// - /// - /// - public static IObservable> WhereReasonsAreNot(this IObservable> source, params ListChangeReason[] reasons) - where T : notnull - { - reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); - - if (reasons.Length == 0) - { - throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); - } - - if (reasons.Length == 1 && reasons[0] == ListChangeReason.Refresh) - { - // If only refresh changes are removed, then there's no need to remove the indexes - return source.Select(changes => - { - var filtered = changes.Where(c => c.Reason != ListChangeReason.Refresh); - return new ChangeSet(filtered); - }).NotEmpty(); - } - - var matches = new HashSet(reasons); - return source.Select( - updates => - { - var filtered = updates.Where(u => !matches.Contains(u.Reason)).YieldWithoutIndex(); - return new ChangeSet(filtered); - }).NotEmpty(); - } - - /// - /// Applies a logical XOR (symmetric difference) between the source and other streams. - /// Items present in exactly one source are included in the result. - /// - /// The type of the item. - /// The primary source to exclusively combine. - /// The other changeset streams to combine with. - /// A list changeset stream containing items that exist in exactly one source. - /// is . - /// - /// - /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it exists in exactly one source. - /// If it appears in a second source, it is removed from the result. If it then leaves one source, - /// it re-enters the result. Moved changes are ignored. - /// - /// - /// EventBehavior - /// Add/AddRangeReference count updated. If the item is now in exactly one source, an Add is emitted. If now in two or more, a Remove is emitted. - /// Remove/RemoveRange/ClearReference count decremented. If now in exactly one source, an Add is emitted. If now in zero, a Remove is emitted. - /// ReplaceOld item reference count decremented, new item incremented, with Xor logic applied. - /// RefreshForwarded if item is in the result set. - /// MovedIgnored. - /// - /// - /// - /// - /// - /// - public static IObservable> Xor(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.Xor, others); - } - - /// - /// - /// Applies a logical XOR between a pre-built collection of list changeset sources. - /// - public static IObservable> Xor(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - /// - /// - /// Dynamic XOR: sources can be added or removed from the at runtime. - /// - public static IObservable> Xor(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - /// - /// - /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Xor(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - /// - /// - /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Xor(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - private static IObservable> Combine(this ICollection>> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new Combiner(sources, type).Run(); - } - - private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] others) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - if (others.Length == 0) - { - throw new ArgumentException("Must be at least one item to combine with", nameof(others)); - } - - var items = source.EnumerateOne().Union(others).ToList(); - return new Combiner(items, type).Run(); - } - - private static IObservable> Combine(this IObservableList> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); - var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(changesSetList, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); - var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(changesSetList, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList>> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new DynamicCombiner(sources, type).Run(); - } } From d6dd784f0693c499c9f48257959c73c3f3933b8b Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sat, 6 Jun 2026 12:13:56 -0500 Subject: [PATCH 05/14] Split ObservableCacheEx.cs into per-family partial classes (#1095) * Break ObservableCacheEx.cs into per-family partial classes Splits the 6800-line ObservableCacheEx.cs into 24 smaller partial-class files grouped by operator family. Each method (and all of its overloads) lives in exactly one file. No code, comments, or XML documentation is added, removed, or otherwise modified; this is a pure file reorganization. All 2218 tests pass. * Break ObservableCacheEx.cs into per-family partial classes Splits the monolithic ObservableCacheEx.cs into 19 smaller partial-class files grouped by operator family. The two pre-existing partials (ObservableCacheEx.SortAndBind.cs, ObservableCacheEx.VirtualiseAndPage.cs) are untouched. Each method (and all of its overloads) lives in exactly one file. No code, XML documentation, comments, preprocessor directives, or constants are added, removed, or otherwise modified. The split was generated programmatically with byte-level per-method equality checks against the original. * Alphabetize members within new ObservableCacheEx partial files Sorts members alphabetically by name within each new partial file. Overloads of the same name preserve their original declaration order. Constants sort before methods. Pre-existing partials (SortAndBind, VirtualiseAndPage) are not modified. * Split ObservableCacheEx.cs partials into one file per operator (overload set) Addresses PR review feedback: 1. ONE FILE PER OPERATOR NAME (one overload set per file). The previous split into 19 family files is replaced with 103 per-operator partial files, matching the existing convention set by ObservableCacheEx.SortAndBind.cs and ObservableCacheEx.VirtualiseAndPage.cs. 2. BARE ObservableCacheEx.cs FILE restored to carry the canonical class-level XML documentation. All partials carry the same canonical class summary ('Extensions for dynamic data.') so SA1601 is satisfied and there are no divergent per-file class docs. SortAndBind.cs and VirtualiseAndPage.cs were also updated for consistency. 3. PRIVATE HELPERS placed AFTER all public members within their containing file. Each private helper lives in the alphabetically-first operator file that calls it: - Combine -> And.cs (also called by Except, Or, Xor) - ForForced -> Transform.cs (also called by TransformSafe) - AdaptSelector -> Group.cs (also called by GroupOnObservable) - OnChangeAction -> OnItemAdded.cs (also called by OnItem* family) - TrueFor -> TrueForAll.cs (also called by TrueForAny) - CreateChangeSetTransformer -> TransformManyAsync.cs (also called by TransformManySafeAsync) - DefaultResortOnSourceRefresh const -> MergeManyChangeSets.cs - DefaultSortResetThreshold const -> Sort.cs The byte content of every method body is preserved (verified programmatically). #if/#endif preprocessor regions (SUPPORTS_BINDINGLIST in Bind.cs, SUPPORTS_ASYNC_DISPOSABLE around AsyncDisposeMany) are reconstructed in the new files. * Extract shared private helpers into per-helper partial files Per Jake's review feedback, private helpers used by multiple operators get their own ObservableCacheEx.{HelperName}.cs file, matching the per-operator pattern established for the public surface. Combine -> ObservableCacheEx.Combine.cs (from And.cs) AdaptSelector -> ObservableCacheEx.AdaptSelector.cs (from Group.cs) OnChangeAction -> ObservableCacheEx.OnChangeAction.cs (from OnItemAdded.cs) ForForced -> ObservableCacheEx.ForForced.cs (from Transform.cs) CreateChangeSetTransformer -> ObservableCacheEx.CreateChangeSetTransformer.cs (from TransformManyAsync.cs) TrueFor -> ObservableCacheEx.TrueFor.cs (from TrueForAll.cs) DefaultSortResetThreshold const moved to ObservableCacheEx.cs (the core file). Audit found it is used by both Sort and SortBy, contrary to the original PR body. AsyncDisposeMany #if SUPPORTS_ASYNC_DISPOSABLE wrapping replaced with a project-level Compile Remove. The file body is unconditionally compiled on supported platforms and excluded entirely on unsupported ones (net4*). All extractions are byte-preserving moves with no functional change. Builds clean on all target frameworks (netstandard2.0, net462, net6-net10). Targeted tests pass. (cherry picked from commit ab5bd6b9ee5664985638bf8f1c65623c77a9ac48) --- .../Cache/ObservableCacheEx.Adapt.cs | 69 + .../Cache/ObservableCacheEx.AdaptSelector.cs | 33 + .../Cache/ObservableCacheEx.AddOrUpdate.cs | 113 + .../Cache/ObservableCacheEx.And.cs | 121 + .../ObservableCacheEx.AsObservableCache.cs | 78 + .../ObservableCacheEx.AsyncDisposeMany.cs | 80 + .../Cache/ObservableCacheEx.AutoRefresh.cs | 90 + ...servableCacheEx.AutoRefreshOnObservable.cs | 67 + .../Cache/ObservableCacheEx.Batch.cs | 64 + .../Cache/ObservableCacheEx.BatchIf.cs | 97 + .../Cache/ObservableCacheEx.Bind.cs | 340 + .../Cache/ObservableCacheEx.BufferInitial.cs | 56 + .../Cache/ObservableCacheEx.Cast.cs | 58 + .../Cache/ObservableCacheEx.ChangeKey.cs | 84 + .../Cache/ObservableCacheEx.Clear.cs | 71 + .../Cache/ObservableCacheEx.Clone.cs | 82 + .../Cache/ObservableCacheEx.Combine.cs | 144 + .../Cache/ObservableCacheEx.Convert.cs | 53 + ...vableCacheEx.CreateChangeSetTransformer.cs | 46 + .../ObservableCacheEx.DeferUntilLoaded.cs | 58 + .../Cache/ObservableCacheEx.DisposeMany.cs | 71 + .../Cache/ObservableCacheEx.DistinctValues.cs | 53 + .../Cache/ObservableCacheEx.EditDiff.cs | 139 + .../ObservableCacheEx.EnsureUniqueKeys.cs | 54 + .../Cache/ObservableCacheEx.Except.cs | 129 + .../Cache/ObservableCacheEx.ExpireAfter.cs | 141 + .../Cache/ObservableCacheEx.Filter.cs | 145 + .../ObservableCacheEx.FilterImmutable.cs | 72 + .../ObservableCacheEx.FilterOnObservable.cs | 95 + .../Cache/ObservableCacheEx.FinallySafe.cs | 43 + .../Cache/ObservableCacheEx.Flatten.cs | 46 + .../ObservableCacheEx.FlattenBufferResult.cs | 45 + .../Cache/ObservableCacheEx.ForEachChange.cs | 62 + .../Cache/ObservableCacheEx.ForForced.cs | 43 + .../Cache/ObservableCacheEx.FullJoin.cs | 106 + .../Cache/ObservableCacheEx.FullJoinMany.cs | 107 + .../Cache/ObservableCacheEx.Group.cs | 166 + .../ObservableCacheEx.GroupOnObservable.cs | 104 + .../ObservableCacheEx.GroupOnProperty.cs | 50 + ...cheEx.GroupOnPropertyWithImmutableState.cs | 50 + ...servableCacheEx.GroupWithImmutableState.cs | 66 + ...rvableCacheEx.IgnoreSameReferenceUpdate.cs | 38 + .../ObservableCacheEx.IgnoreUpdateWhen.cs | 54 + .../ObservableCacheEx.IncludeUpdateWhen.cs | 51 + .../Cache/ObservableCacheEx.InnerJoin.cs | 106 + .../Cache/ObservableCacheEx.InnerJoinMany.cs | 106 + .../Cache/ObservableCacheEx.InvokeEvaluate.cs | 48 + .../Cache/ObservableCacheEx.LeftJoin.cs | 106 + .../Cache/ObservableCacheEx.LeftJoinMany.cs | 106 + .../Cache/ObservableCacheEx.LimitSizeTo.cs | 106 + .../ObservableCacheEx.MergeChangeSets.cs | 434 ++ .../Cache/ObservableCacheEx.MergeMany.cs | 83 + .../ObservableCacheEx.MergeManyChangeSets.cs | 437 ++ .../Cache/ObservableCacheEx.MergeManyItems.cs | 62 + .../Cache/ObservableCacheEx.MonitorStatus.cs | 39 + .../Cache/ObservableCacheEx.NotEmpty.cs | 45 + .../Cache/ObservableCacheEx.OfType.cs | 57 + .../Cache/ObservableCacheEx.OnChangeAction.cs | 50 + .../Cache/ObservableCacheEx.OnItemAdded.cs | 74 + .../ObservableCacheEx.OnItemRefreshed.cs | 72 + .../Cache/ObservableCacheEx.OnItemRemoved.cs | 92 + .../Cache/ObservableCacheEx.OnItemUpdated.cs | 73 + src/DynamicData/Cache/ObservableCacheEx.Or.cs | 131 + .../Cache/ObservableCacheEx.PopulateFrom.cs | 68 + .../Cache/ObservableCacheEx.PopulateInto.cs | 91 + .../ObservableCacheEx.QueryWhenChanged.cs | 91 + .../Cache/ObservableCacheEx.RefCount.cs | 45 + .../Cache/ObservableCacheEx.Refresh.cs | 82 + .../Cache/ObservableCacheEx.Remove.cs | 129 + .../Cache/ObservableCacheEx.RemoveKey.cs | 68 + .../Cache/ObservableCacheEx.RemoveKeys.cs | 44 + .../Cache/ObservableCacheEx.RightJoin.cs | 106 + .../Cache/ObservableCacheEx.RightJoinMany.cs | 107 + .../Cache/ObservableCacheEx.SkipInitial.cs | 47 + .../Cache/ObservableCacheEx.Sort.cs | 119 + .../Cache/ObservableCacheEx.SortAndBind.cs | 2 +- .../Cache/ObservableCacheEx.SortBy.cs | 62 + .../Cache/ObservableCacheEx.StartWithEmpty.cs | 95 + .../Cache/ObservableCacheEx.StartWithItem.cs | 60 + .../Cache/ObservableCacheEx.SubscribeMany.cs | 85 + .../ObservableCacheEx.SuppressRefresh.cs | 38 + .../Cache/ObservableCacheEx.Switch.cs | 60 + .../Cache/ObservableCacheEx.ToCollection.cs | 39 + ...ObservableCacheEx.ToObservableChangeSet.cs | 95 + .../ObservableCacheEx.ToObservableOptional.cs | 97 + .../ObservableCacheEx.ToSortedCollection.cs | 61 + .../Cache/ObservableCacheEx.Transform.cs | 181 + .../Cache/ObservableCacheEx.TransformAsync.cs | 143 + .../ObservableCacheEx.TransformImmutable.cs | 69 + .../Cache/ObservableCacheEx.TransformMany.cs | 84 + .../ObservableCacheEx.TransformManyAsync.cs | 128 + ...bservableCacheEx.TransformManySafeAsync.cs | 123 + ...ObservableCacheEx.TransformOnObservable.cs | 93 + .../Cache/ObservableCacheEx.TransformSafe.cs | 127 + .../ObservableCacheEx.TransformSafeAsync.cs | 129 + .../ObservableCacheEx.TransformToTree.cs | 59 + ...rvableCacheEx.TransformWithInlineUpdate.cs | 111 + ...ObservableCacheEx.TreatMovesAsRemoveAdd.cs | 60 + .../Cache/ObservableCacheEx.TrueFor.cs | 32 + .../Cache/ObservableCacheEx.TrueForAll.cs | 78 + .../Cache/ObservableCacheEx.TrueForAny.cs | 72 + .../Cache/ObservableCacheEx.UpdateIndex.cs | 39 + .../ObservableCacheEx.VirtualiseAndPage.cs | 2 +- .../Cache/ObservableCacheEx.Watch.cs | 55 + .../Cache/ObservableCacheEx.WatchValue.cs | 75 + ...bservableCacheEx.WhenAnyPropertyChanged.cs | 65 + .../ObservableCacheEx.WhenPropertyChanged.cs | 64 + .../ObservableCacheEx.WhenValueChanged.cs | 67 + .../ObservableCacheEx.WhereReasonsAre.cs | 57 + .../ObservableCacheEx.WhereReasonsAreNot.cs | 56 + .../Cache/ObservableCacheEx.Xor.cs | 132 + src/DynamicData/Cache/ObservableCacheEx.cs | 6808 +---------------- src/DynamicData/DynamicData.csproj | 5 + 113 files changed, 9758 insertions(+), 6808 deletions(-) create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Adapt.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AdaptSelector.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AddOrUpdate.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.And.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AsObservableCache.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AsyncDisposeMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Batch.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.BatchIf.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Bind.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.BufferInitial.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Cast.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ChangeKey.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Clear.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Clone.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Combine.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Convert.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.CreateChangeSetTransformer.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.DeferUntilLoaded.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.DisposeMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.DistinctValues.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.EditDiff.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.EnsureUniqueKeys.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Except.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ExpireAfter.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Filter.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.FilterImmutable.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.FilterOnObservable.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.FinallySafe.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Flatten.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.FlattenBufferResult.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ForEachChange.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ForForced.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.FullJoin.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.FullJoinMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Group.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.GroupOnObservable.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.GroupOnProperty.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.GroupOnPropertyWithImmutableState.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.GroupWithImmutableState.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.IgnoreSameReferenceUpdate.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.IgnoreUpdateWhen.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.IncludeUpdateWhen.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.InnerJoin.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.InnerJoinMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.InvokeEvaluate.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.LeftJoin.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.LeftJoinMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.LimitSizeTo.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.MergeChangeSets.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.MergeManyItems.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.MonitorStatus.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.NotEmpty.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.OfType.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.OnChangeAction.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.OnItemAdded.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.OnItemRefreshed.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.OnItemRemoved.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.OnItemUpdated.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Or.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.PopulateFrom.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.PopulateInto.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.QueryWhenChanged.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.RefCount.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Refresh.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Remove.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.RemoveKey.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.RemoveKeys.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.RightJoin.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.RightJoinMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.SkipInitial.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Sort.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.SortBy.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.StartWithEmpty.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.StartWithItem.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.SubscribeMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.SuppressRefresh.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Switch.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ToCollection.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ToObservableOptional.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ToSortedCollection.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Transform.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformImmutable.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformManyAsync.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformManySafeAsync.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformToTree.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformWithInlineUpdate.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TreatMovesAsRemoveAdd.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TrueFor.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TrueForAll.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TrueForAny.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.UpdateIndex.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Watch.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.WatchValue.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAre.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAreNot.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Xor.cs diff --git a/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs b/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs new file mode 100644 index 000000000..f99f46747 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Injects a side effect into the changeset stream by calling . + /// for every changeset, then forwarding it downstream unchanged. + /// + /// The type of items in the cache. + /// The type of the key. + /// The source to observe and adapt. + /// The whose Adapt method is called for each changeset. + /// An observable that emits the same changesets as , after the adaptor has processed each one. + /// + /// + /// This is a thin wrapper around Rx's Do operator. The adaptor receives each changeset + /// as a side effect; the changeset itself is forwarded downstream unmodified. + /// + /// + /// or is . + /// + /// + public static IObservable> Adapt(this IObservable> source, IChangeSetAdaptor adaptor) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); + + return source.Do(adaptor.Adapt); + } + + /// + /// The source to observe and adapt. + /// The whose Adapt method is called for each changeset. + /// This overload operates on . Delegates to Rx's Do operator. + public static IObservable> Adapt(this IObservable> source, ISortedChangeSetAdaptor adaptor) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); + + return source.Do(adaptor.Adapt); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.AdaptSelector.cs b/src/DynamicData/Cache/ObservableCacheEx.AdaptSelector.cs new file mode 100644 index 000000000..07c984029 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AdaptSelector.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + // TODO: Apply the Adapter to more places + private static Func AdaptSelector(Func other) + where TObject : notnull + where TKey : notnull + where TResult : notnull => (obj, _) => other(obj); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.AddOrUpdate.cs b/src/DynamicData/Cache/ObservableCacheEx.AddOrUpdate.cs new file mode 100644 index 000000000..3b217f6ed --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AddOrUpdate.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Adds or updates the cache with the specified item, producing a changeset with a single Add + /// (if the key is new) or Update (if the key already exists). + /// + /// The type of the object. + /// The type of the key. + /// The to add or update items in. + /// The item to add or update. + /// + /// Convenience method that wraps a single-item mutation inside . + /// + /// EventBehavior + /// AddProduced when the key does not already exist in the cache. + /// UpdateProduced when the key already exists. The previous value is included in the changeset. + /// RemoveNot produced by this method. + /// RefreshNot produced by this method. + /// + /// + /// is . + /// + /// + public static void AddOrUpdate(this ISourceCache source, TObject item) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.AddOrUpdate(item)); + } + + /// + /// The to add or update items in. + /// The item to add or update. + /// The used to determine whether a new item is the same as an existing cached item. When equal, the update is skipped. + /// This overload uses to suppress no-op updates when the new value equals the existing one. + public static void AddOrUpdate(this ISourceCache source, TObject item, IEqualityComparer equalityComparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.AddOrUpdate(item, equalityComparer)); + } + + /// + /// The to add or update items in. + /// The of items to add or update. + /// Batch overload. All items are added/updated inside a single call, producing one changeset. + public static void AddOrUpdate(this ISourceCache source, IEnumerable items) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.AddOrUpdate(items)); + } + + /// + /// The to add or update items in. + /// The of items to add or update. + /// The used to determine whether a new item is the same as an existing cached item. When equal, the update is skipped. + /// Batch overload with equality comparison. All items are added/updated inside a single call. + public static void AddOrUpdate(this ISourceCache source, IEnumerable items, IEqualityComparer equalityComparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.AddOrUpdate(items, equalityComparer)); + } + + /// + /// The to add or update items in. + /// The item to add or update. + /// The key to associate with the item. + /// This overload operates on , which requires an explicit key parameter. + public static void AddOrUpdate(this IIntermediateCache source, TObject item, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + item.ThrowArgumentNullExceptionIfNull(nameof(item)); + + source.Edit(updater => updater.AddOrUpdate(item, key)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.And.cs b/src/DynamicData/Cache/ObservableCacheEx.And.cs new file mode 100644 index 000000000..bae09dd4e --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.And.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Applied a logical And operator between the collections i.e items which are in all of the + /// sources are included. + /// + /// The type of the object. + /// The type of the key. + /// The source to combine. + /// The additional streams to combine with. + /// An observable which emits change sets. + /// source or others. + /// + public static IObservable> And(this IObservable> source, params IObservable>[] others) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return others is null || others.Length == 0 + ? throw new ArgumentNullException(nameof(others)) + : source.Combine(CombineOperator.And, others); + } + + /// + /// Applied a logical And operator between the collections i.e items which are in all of the sources are included. + /// + /// The type of the object. + /// The type of the key. + /// The of streams to combine. + /// An observable which emits change sets. + /// + /// source + /// or + /// others. + /// + public static IObservable> And(this ICollection>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.And); + } + + /// + /// Dynamically apply a logical And operator between the items in the outer observable list. + /// Items which are in all of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of streams to combine. + /// An observable which emits change sets. + public static IObservable> And(this IObservableList>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.And); + } + + /// + /// Dynamically apply a logical And operator between the items in the outer observable list. + /// Items which are in all of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits change sets. + public static IObservable> And(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.And); + } + + /// + /// Dynamically apply a logical And operator between the items in the outer observable list. + /// Items which are in all of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits change sets. + public static IObservable> And(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.And); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.AsObservableCache.cs b/src/DynamicData/Cache/ObservableCacheEx.AsObservableCache.cs new file mode 100644 index 000000000..3a9f58875 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AsObservableCache.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Wraps an in a read-only facade, hiding the mutable API. + /// + /// The type of the object. + /// The type of the key. + /// The to operate on. + /// A read-only . + /// is . + /// + public static IObservableCache AsObservableCache(this IObservableCache source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new AnonymousObservableCache(source); + } + + /// + /// Materializes a changeset stream into a queryable, read-only . + /// The cache subscribes to the source on first access and maintains a live snapshot of all items. + /// + /// The type of the object. + /// The type of the key. + /// The source to materialize into a read-only cache. + /// If (default), all cache operations are synchronized. Set to when the caller guarantees single-threaded access. + /// A read-only observable cache that reflects the current state of the pipeline. + /// + /// + /// Disposing the returned cache unsubscribes from the source stream. The cache's Connect() + /// method provides a changeset stream of its own, which re-emits the current state on each new subscriber. + /// + /// When is , a is used internally. + /// + /// is . + /// + /// + public static IObservableCache AsObservableCache(this IObservable> source, bool applyLocking = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (applyLocking) + { + return new AnonymousObservableCache(source); + } + + return new LockFreeObservableCache(source); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.AsyncDisposeMany.cs b/src/DynamicData/Cache/ObservableCacheEx.AsyncDisposeMany.cs new file mode 100644 index 000000000..0a15843d5 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AsyncDisposeMany.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// + /// Disposes items implementing or when they are removed or replaced, + /// and disposes all tracked items when the stream completes, errors, or the subscription is disposed. + /// + /// + /// Individual items are disposed after the changeset has been forwarded downstream, so downstream operators + /// see the removal before disposal occurs. Items implementing neither disposal interface are ignored. + /// + /// + /// The type of items in the cache. + /// The type of the key. + /// The source to track for async disposal on removal. + /// + /// + /// Invoked once per subscription, providing an that signals when all + /// calls have finished. The signal emits a single value + /// and then completes. + /// + /// + /// This is delivered on a separate channel from the main changeset stream so it can be observed even + /// if the source stream errors. + /// + /// + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddTracks the item. No disposal. + /// UpdateDisposes the previous value (if it differs by reference from the current). Tracks the new value. + /// RemoveDisposes the removed item. + /// RefreshPassed through. No disposal. + /// + /// + /// + /// On stream completion, error, or subscription disposal, all items still in the cache are disposed. + /// items are disposed synchronously; items + /// are dispatched via the signal. + /// + /// + /// or is . + /// + public static IObservable> AsyncDisposeMany( + this IObservable> source, + Action> disposalsCompletedAccessor) + where TObject : notnull + where TKey : notnull + => Cache.Internal.AsyncDisposeMany.Create( + source: source, + disposalsCompletedAccessor: disposalsCompletedAccessor); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs new file mode 100644 index 000000000..1e7bfb428 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Automatically refresh downstream operators when any properties change. + /// + /// The object of the change set. + /// The key of the change set. + /// The source to monitor for property-driven refresh signals. + /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements have successive property changes. + /// An optional throttle applied to each item's property change notifications, preventing excessive refresh invocations. + /// An optional for scheduling work. + /// An observable change set with additional refresh changes. + /// + public static IObservable> AutoRefresh(this IObservable> source, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.AutoRefreshOnObservable( + (t, _) => + { + if (propertyChangeThrottle is null) + { + return t.WhenAnyPropertyChanged(); + } + + return t.WhenAnyPropertyChanged().Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); + }, + changeSetBuffer, + scheduler); + } + + /// + /// Automatically refresh downstream operators when properties change. + /// + /// The object of the change set. + /// The key of the change set. + /// The type of the property. + /// The source to monitor for property-driven refresh signals. + /// A that specify a property to observe changes. When it changes a Refresh is invoked. + /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements have successive property changes. + /// An optional throttle applied to each item's property change notifications, preventing excessive refresh invocations. + /// An optional for scheduling work. + /// An observable change set with additional refresh changes. + public static IObservable> AutoRefresh(this IObservable> source, Expression> propertyAccessor, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.AutoRefreshOnObservable( + (t, _) => + { + if (propertyChangeThrottle is null) + { + return t.WhenPropertyChanged(propertyAccessor, false); + } + + return t.WhenPropertyChanged(propertyAccessor, false).Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); + }, + changeSetBuffer, + scheduler); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs new file mode 100644 index 000000000..39a04294e --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Automatically refresh downstream operator. The refresh is triggered when the observable receives a notification. + /// + /// The object of the change set. + /// The key of the change set. + /// The type of evaluation. + /// The source to monitor for observable-driven refresh signals. + /// The observable which acts on items within the collection and produces a value when the item should be refreshed. + /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements require a refresh. + /// An optional for scheduling work. + /// An observable change set with additional refresh changes. + /// + public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull => source.AutoRefreshOnObservable((t, _) => reevaluator(t), changeSetBuffer, scheduler); + + /// + /// Automatically refresh downstream operator. The refresh is triggered when the observable receives a notification. + /// + /// The object of the change set. + /// The key of the change set. + /// The type of evaluation. + /// The source to monitor for observable-driven refresh signals. + /// The observable which acts on items within the collection and produces a value when the item should be refreshed. + /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements require a refresh. + /// An optional for scheduling work. + /// An observable change set with additional refresh changes. + /// + /// Worth noting: Per-item observable errors are silently ignored (not forwarded to the downstream observer). Only source stream errors propagate. + /// + public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); + + return new AutoRefresh(source, reevaluator, changeSetBuffer, scheduler).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Batch.cs b/src/DynamicData/Cache/ObservableCacheEx.Batch.cs new file mode 100644 index 000000000..1ed843deb --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Batch.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Collects changesets emitted within a time window and merges them into a single changeset. + /// Uses Rx's Buffer operator followed by . + /// + /// The type of the object. + /// The type of the key. + /// The source to batch. + /// The time window for batching. + /// The scheduler for timing. Defaults to . + /// An observable that emits merged changesets, one per time window. + /// + /// + /// All changesets received during the time window are concatenated into a single changeset. + /// This is useful for reducing UI update frequency when the source emits many rapid changes. + /// + /// + /// EventBehavior + /// AddBuffered and included in the merged changeset at the end of the time window. + /// UpdateBuffered and included in the merged changeset. + /// RemoveBuffered and included in the merged changeset. + /// RefreshBuffered and included in the merged changeset. + /// OnCompletedAny remaining buffered changes are flushed, then completion is forwarded. + /// + /// Worth noting: The merged changeset may contain contradictory changes (e.g., Add then Remove for the same key). Downstream operators handle this correctly, but raw inspection of the changeset may be surprising. + /// + /// is . + /// + /// + public static IObservable> Batch(this IObservable> source, TimeSpan timeSpan, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Buffer(timeSpan, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.BatchIf.cs b/src/DynamicData/Cache/ObservableCacheEx.BatchIf.cs new file mode 100644 index 000000000..99fb5ee13 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.BatchIf.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// This overload delegates to the primary overload with initialPauseState: false. + public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull => BatchIf(source, pauseIfTrueSelector, false, scheduler); + + /// + /// This overload delegates to the primary overload with default initialPauseState: false. + public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState = false, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull => new BatchIf(source, pauseIfTrueSelector, null, initialPauseState, scheduler: scheduler).Run(); + + /// + /// This overload omits initialPauseState (defaults to ) but accepts a timeout. + public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, TimeSpan? timeOut = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull => BatchIf(source, pauseIfTrueSelector, false, timeOut, scheduler); + + /// + /// Conditionally buffers changesets while a pause signal is active, then flushes all buffered + /// changes as a single merged changeset when the signal resumes. + /// + /// The type of the object. + /// The type of the key. + /// The source to conditionally buffer. + /// An that when , buffering begins. When , the buffer is flushed. + /// If , starts in a paused (buffering) state. + /// A that maximum time the buffer stays open. When elapsed, the buffer is flushed regardless of pause state. + /// The for timeout timing. + /// An observable that emits changesets, buffered or passthrough depending on pause state. + /// + /// + /// While paused, incoming changesets are accumulated. On resume (or timeout), all buffered changesets + /// are merged into a single changeset and emitted. While not paused, changesets pass through immediately. + /// + /// + /// EventBehavior + /// AddBuffered while paused; forwarded immediately while active. + /// UpdateBuffered while paused; forwarded immediately while active. + /// RemoveBuffered while paused; forwarded immediately while active. + /// RefreshBuffered while paused; forwarded immediately while active. + /// OnErrorBuffered data is lost. + /// OnCompletedAny remaining buffered data is flushed before completion. + /// + /// Worth noting: If the source completes while paused, buffered data IS flushed before OnCompleted. However, if the source errors while paused, buffered data is lost. + /// + /// or is . + /// + /// + public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState = false, TimeSpan? timeOut = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); + + return new BatchIf(source, pauseIfTrueSelector, timeOut, initialPauseState, scheduler: scheduler).Run(); + } + + /// + /// The source to conditionally buffer. + /// An that controls buffering: begins buffering, flushes the buffer. + /// If , starts in a paused (buffering) state. + /// An optional timer. The buffer is flushed each time the timer produces a value, and buffering ceases when it completes. + /// An optional for scheduling work. + /// This overload accepts an explicit timer observable instead of a timeout. + public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState = false, IObservable? timer = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull => new BatchIf(source, pauseIfTrueSelector, null, initialPauseState, timer, scheduler).Run(); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Bind.cs b/src/DynamicData/Cache/ObservableCacheEx.Bind.cs new file mode 100644 index 000000000..84991bd6e --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Bind.cs @@ -0,0 +1,340 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Binds the results to the specified observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The number of changes before a reset notification is triggered. + /// An observable which will emit change sets. + /// source. + /// + public static IObservable> Bind(this IObservable> source, IObservableCollection destination, int refreshThreshold = BindingOptions.DefaultResetThreshold) + where TObject : notnull + where TKey : notnull + { + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + + var options = refreshThreshold == BindingOptions.DefaultResetThreshold + ? defaults + : defaults with { ResetThreshold = refreshThreshold }; + + return source?.Bind(destination, new ObservableCollectionAdaptor(options)) ?? throw new ArgumentNullException(nameof(source)); + } + + /// + /// Binds the results to the specified observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The that controls binding behavior. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, IObservableCollection destination, BindingOptions options) + where TObject : notnull + where TKey : notnull + { + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source?.Bind(destination, new ObservableCollectionAdaptor(options)) ?? throw new ArgumentNullException(nameof(source)); + } + + /// + /// Binds the results to the specified binding collection using the specified update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The that applies changes to the bound collection. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, IObservableCollection destination, IObservableCollectionAdaptor updater) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + updater.ThrowArgumentNullExceptionIfNull(nameof(updater)); + + return Observable.Create>( + observer => + { + var locker = InternalEx.NewLock(); + return source.Synchronize(locker).Select( + changes => + { + updater.Adapt(changes, destination); + return changes; + }).SubscribeSafe(observer); + }); + } + + /// + /// Binds the results to the specified readonly observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The output that will be populated with the results. + /// The that controls binding behavior. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + var target = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(target); + return source.Bind(target, new ObservableCollectionAdaptor(options)); + } + + /// + /// Binds the results to the specified readonly observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The output that will be populated with the results. + /// The number of changes before a reset notification is triggered. + /// When , uses Replace instead of Remove/Add for updates in the bound collection. Not all platforms support replace notifications. + /// An optional that controls how the target collection is updated. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold, bool useReplaceForUpdates = BindingOptions.DefaultUseReplaceForUpdates, IObservableCollectionAdaptor? adaptor = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (adaptor is not null) + { + var target = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(target); + return source.Bind(target, adaptor); + } + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + + var options = resetThreshold == BindingOptions.DefaultResetThreshold && useReplaceForUpdates == BindingOptions.DefaultUseReplaceForUpdates + ? defaults + : defaults with { ResetThreshold = resetThreshold, UseReplaceForUpdates = useReplaceForUpdates }; + + return source.Bind(out readOnlyObservableCollection, options); + } + + /// + /// Binds the results to the specified observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The that will receive the changes. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, IObservableCollection destination) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source.Bind(destination, DynamicDataOptions.Binding); + } + + /// + /// Binds the results to the specified observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The that controls binding behavior. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, IObservableCollection destination, BindingOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + var updater = new SortedObservableCollectionAdaptor(options); + return source.Bind(destination, updater); + } + + /// + /// Binds the results to the specified binding collection using the specified update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The that applies changes to the bound collection. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, IObservableCollection destination, ISortedObservableCollectionAdaptor updater) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + updater.ThrowArgumentNullExceptionIfNull(nameof(updater)); + + return Observable.Create>( + observer => + { + var locker = InternalEx.NewLock(); + return source.Synchronize(locker).Select( + changes => + { + updater.Adapt(changes, destination); + return changes; + }).SubscribeSafe(observer); + }); + } + + /// + /// Binds the results to the specified readonly observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The output that will be populated with the results. + /// The that controls binding behavior. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + var target = new ObservableCollectionExtended(); + var result = new ReadOnlyObservableCollection(target); + var updater = new SortedObservableCollectionAdaptor(options); + readOnlyObservableCollection = result; + return source.Bind(target, updater); + } + + /// + /// Binds the results to the specified readonly observable collection using the default update algorithm. + /// + /// The type of the object. + /// The type of the key. + /// The source to bind to a collection. + /// The output that will be populated with the results. + /// The number of changes before a reset event is called on the observable collection. + /// When , uses Replace instead of Remove/Add for updates in the bound collection. Not all platforms support replace notifications. + /// An that specify an adaptor to change the algorithm to update the target collection. + /// An observable which will emit change sets. + /// source. + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold, bool useReplaceForUpdates = BindingOptions.DefaultUseReplaceForUpdates, ISortedObservableCollectionAdaptor? adaptor = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + var options = resetThreshold == BindingOptions.DefaultResetThreshold && useReplaceForUpdates == BindingOptions.DefaultUseReplaceForUpdates + ? defaults + : defaults with { ResetThreshold = resetThreshold, UseReplaceForUpdates = useReplaceForUpdates }; + + adaptor ??= new SortedObservableCollectionAdaptor(options); + + var target = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(target); + return source.Bind(target, adaptor); + } + +#if SUPPORTS_BINDINGLIST + + /// + /// Binds a clone of the observable change set to the target observable collection. + /// + /// The object type. + /// The key type. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The reset threshold. + /// An observable which will emit change sets. + /// + /// source + /// or + /// targetCollection. + /// + public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TObject, TKey>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); + + return source.Adapt(new BindingListAdaptor(bindingList, resetThreshold)); + } + + /// + /// Binds a clone of the observable change set to the target observable collection. + /// + /// The object type. + /// The key type. + /// The source to bind to a collection. + /// The that will receive the changes. + /// The reset threshold. + /// An observable which will emit change sets. + /// + /// source + /// or + /// targetCollection. + /// + public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TObject, TKey>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); + + return source.Adapt(new SortedBindingListAdaptor(bindingList, resetThreshold)); + } + +#endif +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.BufferInitial.cs b/src/DynamicData/Cache/ObservableCacheEx.BufferInitial.cs new file mode 100644 index 000000000..c8d709c85 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.BufferInitial.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Buffers the initial burst of changesets for the specified duration, merges them into a single + /// changeset, then passes all subsequent changesets through without buffering. + /// + /// The object type. + /// The type of the key. + /// The source to buffer during the initial loading period. + /// The time window to buffer, measured from when the first changeset arrives. + /// The scheduler for timing. Defaults to . + /// An observable that emits one merged changeset for the initial burst, then passthrough for the rest. + /// + /// + /// Useful for aggregating the initial snapshot (which may arrive as many small changesets) into a + /// single changeset for efficient downstream processing, while leaving subsequent live updates untouched. + /// + /// Internally uses , Rx Buffer, and . + /// + /// + /// + public static IObservable> BufferInitial(this IObservable> source, TimeSpan initialBuffer, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull => source.DeferUntilLoaded().Publish( + shared => + { + var initial = shared.Buffer(initialBuffer, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult().Take(1); + + return initial.Concat(shared); + }); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Cast.cs b/src/DynamicData/Cache/ObservableCacheEx.Cast.cs new file mode 100644 index 000000000..90aa68b00 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Cast.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Casts each item in the changeset to a new type using the provided converter function. + /// Equivalent to + /// but named for discoverability when a simple type cast or conversion is needed. + /// + /// The type of the source object. + /// The type of the key. + /// The type of the destination object. + /// The source to cast. + /// The conversion function applied to each item. + /// An observable changeset of converted items. + /// + /// + /// EventBehavior + /// AddCalls and emits an Add with the converted item. + /// UpdateCalls on the new value and emits an Update. + /// RemoveEmits a Remove. The converter is not called. + /// RefreshForwarded as Refresh. The converter is not called. + /// + /// + /// + public static IObservable> Cast(this IObservable> source, Func converter) + where TSource : notnull + where TKey : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new Cast(source, converter).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ChangeKey.cs b/src/DynamicData/Cache/ObservableCacheEx.ChangeKey.cs new file mode 100644 index 000000000..53dc96e5d --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ChangeKey.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Re-keys each item in the changeset by applying to the current item. + /// The original change reason is preserved; only the key is remapped. + /// + /// The type of the object. + /// The type of the source key. + /// The type of the destination key. + /// The source to re-key. + /// The that computes the destination key from the item, e.g. (item) => item.NewId. + /// An observable changeset with items re-keyed using . + /// + /// + /// EventBehavior + /// Add is called on the item. An Add is emitted with the destination key. + /// Update is called on the current item. An Update is emitted with the destination key. If the key selector produces a different destination key for the updated value than it did for the original value, downstream consumers will see an Update for a key that may not match the original Add. + /// Remove is called on the item. A Remove is emitted with the destination key. + /// Refresh is called on the item. A Refresh is emitted with the destination key. + /// + /// + /// + public static IObservable> ChangeKey(this IObservable> source, Func keySelector) + where TObject : notnull + where TSourceKey : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return source.Select( + updates => + { + var changed = updates.Select(u => new Change(u.Reason, keySelector(u.Current), u.Current, u.Previous)); + return new ChangeSet(changed); + }); + } + + /// + /// + /// This overload also provides the source key to , + /// allowing the destination key to be derived from both the item and its original key. + /// + public static IObservable> ChangeKey(this IObservable> source, Func keySelector) + where TObject : notnull + where TSourceKey : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return source.Select( + updates => + { + var changed = updates.Select(u => new Change(u.Reason, keySelector(u.Key, u.Current), u.Current, u.Previous)); + return new ChangeSet(changed); + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Clear.cs b/src/DynamicData/Cache/ObservableCacheEx.Clear.cs new file mode 100644 index 000000000..d29301dc8 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Clear.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Removes all items from the cache, producing a changeset with a Remove for every item. + /// + /// The type of the object. + /// The type of the key. + /// The to clear. + /// + /// + /// EventBehavior + /// AddNot produced by this operation. + /// UpdateNot produced by this operation. + /// RemoveA Remove is emitted for every item currently in the cache. + /// RefreshNot produced by this operation. + /// + /// + /// is . + public static void Clear(this ISourceCache source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Clear()); + } + + /// + public static void Clear(this IIntermediateCache source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Clear()); + } + + /// + public static void Clear(this LockFreeObservableCache source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + source.Edit(updater => updater.Clear()); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Clone.cs b/src/DynamicData/Cache/ObservableCacheEx.Clone.cs new file mode 100644 index 000000000..1f635fc58 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Clone.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Applies each change from the source changeset to the specified collection as a side effect. + /// The changeset is forwarded downstream unchanged. + /// + /// The type of the object. + /// The type of the key. + /// The source to clone. + /// The target collection to which changes are applied. + /// An observable that forwards all changesets from unchanged. + /// + /// + /// EventBehavior + /// AddThe item is added to . Forwarded as Add. + /// UpdateThe previous item is removed from and the current item is added. Forwarded as Update. + /// RemoveThe item is removed from . Forwarded as Remove. + /// RefreshIgnored ( has no concept of refresh). Forwarded as Refresh. + /// + /// + public static IObservable> Clone(this IObservable> source, ICollection target) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + target.ThrowArgumentNullExceptionIfNull(nameof(target)); + + return source.Do( + changes => + { + foreach (var item in changes.ToConcreteType()) + { + switch (item.Reason) + { + case ChangeReason.Add: + { + target.Add(item.Current); + } + + break; + + case ChangeReason.Update: + { + target.Remove(item.Previous.Value); + target.Add(item.Current); + } + + break; + + case ChangeReason.Remove: + target.Remove(item.Current); + break; + } + } + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Combine.cs b/src/DynamicData/Cache/ObservableCacheEx.Combine.cs new file mode 100644 index 000000000..eb46b86b3 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Combine.cs @@ -0,0 +1,144 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + private static IObservable> Combine(this IObservableList> source, CombineOperator type) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return Observable.Create>( + observer => + { + var connections = source.Connect().Transform(x => x.Connect()).AsObservableList(); + var subscriber = connections.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(connections, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList> source, CombineOperator type) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return Observable.Create>( + observer => + { + var connections = source.Connect().Transform(x => x.Connect()).AsObservableList(); + var subscriber = connections.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(connections, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList>> source, CombineOperator type) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DynamicCombiner(source, type).Run(); + } + + private static IObservable> Combine(this ICollection>> sources, CombineOperator type) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + void UpdateAction(IChangeSet updates) + { + try + { + observer.OnNext(updates); + } + catch (Exception ex) + { + observer.OnError(ex); + } + } + + var subscriber = Disposable.Empty; + try + { + var combiner = new Combiner(type, UpdateAction); + subscriber = combiner.Subscribe([.. sources]); + } + catch (Exception ex) + { + observer.OnError(ex); + observer.OnCompleted(); + } + + return subscriber; + }); + } + + private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] combineTarget) + where TObject : notnull + where TKey : notnull + { + combineTarget.ThrowArgumentNullExceptionIfNull(nameof(combineTarget)); + + return Observable.Create>( + observer => + { + void UpdateAction(IChangeSet updates) + { + try + { + observer.OnNext(updates); + } + catch (Exception ex) + { + observer.OnError(ex); + observer.OnCompleted(); + } + } + + var subscriber = Disposable.Empty; + try + { + var list = combineTarget.ToList(); + list.Insert(0, source); + + var combiner = new Combiner(type, UpdateAction); + subscriber = combiner.Subscribe([.. list]); + } + catch (Exception ex) + { + observer.OnError(ex); + observer.OnCompleted(); + } + + return subscriber; + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Convert.cs b/src/DynamicData/Cache/ObservableCacheEx.Convert.cs new file mode 100644 index 000000000..bf6d26c18 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Convert.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Obsolete: use instead. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The source to convert. + /// The conversion factory. + /// An observable which emits change sets. + [Obsolete("This was an experiment that did not work. Use Transform instead")] + public static IObservable> Convert(this IObservable> source, Func conversionFactory) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); + + return source.Select( + changes => + { + var transformed = changes.Select(change => new Change(change.Reason, change.Key, conversionFactory(change.Current), change.Previous.Convert(conversionFactory), change.CurrentIndex, change.PreviousIndex)); + return new ChangeSet(transformed); + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.CreateChangeSetTransformer.cs b/src/DynamicData/Cache/ObservableCacheEx.CreateChangeSetTransformer.cs new file mode 100644 index 000000000..47d11d3ca --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.CreateChangeSetTransformer.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + private static Func>>> CreateChangeSetTransformer(Func>> manySelector, Func keySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => async (val, key) => (await manySelector(val, key).ConfigureAwait(false)).AsObservableChangeSet(keySelector); + + private static Func>>> CreateChangeSetTransformer(Func> manySelector, Func keySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + where TCollection : INotifyCollectionChanged, IEnumerable => async (val, key) => (await manySelector(val, key).ConfigureAwait(false)).ToObservableChangeSet().AddKey(keySelector); + + private static Func>>> CreateChangeSetTransformer(Func>> manySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => async (val, key) => (await manySelector(val, key).ConfigureAwait(false)).Connect(); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.DeferUntilLoaded.cs b/src/DynamicData/Cache/ObservableCacheEx.DeferUntilLoaded.cs new file mode 100644 index 000000000..89cebf0da --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.DeferUntilLoaded.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Suppresses all emissions until the first non-empty changeset arrives, then replays that changeset and all subsequent ones. + /// If the source never produces a non-empty changeset, the stream waits indefinitely. + /// + /// The type of the object. + /// The type of the key. + /// The source to defer until the first changeset arrives. + /// An observable that begins emitting changesets once the first non-empty changeset is received. + /// + /// Worth noting: Blocks indefinitely if the cache or stream never receives any data. Ensure the source will eventually emit at least one changeset. + /// + /// + public static IObservable> DeferUntilLoaded(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DeferUntilLoaded(source).Run(); + } + + /// + public static IObservable> DeferUntilLoaded(this IObservableCache source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DeferUntilLoaded(source).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.DisposeMany.cs b/src/DynamicData/Cache/ObservableCacheEx.DisposeMany.cs new file mode 100644 index 000000000..52ad51274 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.DisposeMany.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// + /// Disposes items implementing when they are removed or replaced, + /// and disposes all tracked items when the stream completes, errors, or the subscription is disposed. + /// + /// + /// Individual items are disposed after the changeset has been forwarded downstream, so downstream operators + /// see the removal before disposal occurs. Items that do not implement are ignored. + /// + /// + /// The type of the object. + /// The type of the key. + /// The source to track for disposal on removal. + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddTracks the item. No disposal. + /// UpdateDisposes the previous value (if it differs by reference from the current). Tracks the new value. + /// RemoveDisposes the removed item. + /// RefreshPassed through. No disposal. + /// + /// + /// + /// On stream completion, error, or subscription disposal, all remaining tracked items are disposed. + /// All disposal is synchronous via . + /// For items that implement , use instead. + /// + /// + /// is . + /// + /// + /// + public static IObservable> DisposeMany(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DisposeMany(source).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.DistinctValues.cs b/src/DynamicData/Cache/ObservableCacheEx.DistinctValues.cs new file mode 100644 index 000000000..6007f055c --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.DistinctValues.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Selects distinct values from the source. + /// + /// The type object from which the distinct values are selected. + /// The type of the key. + /// The type of the value. + /// The source to extract distinct values. + /// The value selector. + /// An observable which will emit distinct change sets. + /// + /// Due to it's nature only adds or removes can be returned. + /// Worth noting: Reference counting assumes value equality is transitive. Mutable value objects with inconsistent Equals implementations can corrupt ref counts. + /// + /// source. + /// + public static IObservable> DistinctValues(this IObservable> source, Func valueSelector) + where TObject : notnull + where TKey : notnull + where TValue : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + valueSelector.ThrowArgumentNullExceptionIfNull(nameof(valueSelector)); + + return Observable.Create>(observer => new DistinctCalculator(source, valueSelector).Run().SubscribeSafe(observer)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.EditDiff.cs b/src/DynamicData/Cache/ObservableCacheEx.EditDiff.cs new file mode 100644 index 000000000..86a68c29a --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.EditDiff.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The to diff and update. + /// The representing the complete desired state to diff against the cache. + /// An used to determine whether a new item is the same as an existing cached item. + /// + /// This overload uses an instead of a delegate + /// to determine item equality. + /// + public static void EditDiff(this ISourceCache source, IEnumerable allItems, IEqualityComparer equalityComparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + allItems.ThrowArgumentNullExceptionIfNull(nameof(allItems)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + + source.EditDiff(allItems, equalityComparer.Equals); + } + + /// + /// Diffs a complete snapshot of items against the current cache contents, producing the minimal set of + /// Add, Update, and Remove changes needed to bring the cache in sync with the snapshot. + /// + /// The type of the object. + /// The type of the key. + /// The to diff and update. + /// The representing the complete desired state. + /// The that returns when the current and previous items are considered equal, e.g. (current, previous) => current.Version == previous.Version. + /// + /// + /// EventBehavior + /// AddItems in whose key is not in the cache produce an Add. + /// UpdateItems present in both and the cache that differ (per ) produce an Update. + /// RemoveItems in the cache whose key is not in produce a Remove. + /// RefreshNot produced by this operation. + /// + /// + /// , , or is . + public static void EditDiff(this ISourceCache source, IEnumerable allItems, Func areItemsEqual) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + allItems.ThrowArgumentNullExceptionIfNull(nameof(allItems)); + areItemsEqual.ThrowArgumentNullExceptionIfNull(nameof(areItemsEqual)); + + var editDiff = new EditDiff(source, areItemsEqual); + editDiff.Edit(allItems); + } + + /// + /// Converts an of into a changeset stream by diffing each + /// emission against the previous one. Each emission replaces the entire dataset. + /// Counterpart to . + /// + /// The type of the object. + /// The type of the key. + /// The source to convert into a keyed changeset stream. + /// The that extracts the unique key from each item. + /// An optional for comparing items. Uses default equality if . + /// An observable changeset representing the incremental differences between successive snapshots. + /// + /// + /// EventBehavior + /// AddItems in the new snapshot whose key was not in the previous snapshot produce an Add. + /// UpdateItems present in both snapshots that differ (per ) produce an Update. + /// RemoveItems in the previous snapshot whose key is absent from the new snapshot produce a Remove. + /// RefreshNot produced by this operator. + /// + /// + /// or is . + /// + public static IObservable> EditDiff(this IObservable> source, Func keySelector, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return new EditDiffChangeSet(source, keySelector, equalityComparer).Run(); + } + + /// + /// Converts an of into a changeset stream that tracks + /// a single item: Some produces an Add or Update, and None produces a Remove. + /// + /// The type of the object. + /// The type of the key. + /// The source to convert into a keyed changeset stream. + /// The that extracts the unique key from each item. + /// An optional for comparing items. Uses default equality if . + /// An observable changeset tracking the single optional item. + /// + /// + /// EventBehavior + /// AddEmitted when the source produces Some(value) and no item was previously tracked. + /// UpdateEmitted when the source produces Some(value) and an item was already tracked with a different value (per ). + /// RemoveEmitted when the source produces None and an item was previously tracked. + /// RefreshNot produced by this operator. + /// + /// + /// or is . + public static IObservable> EditDiff(this IObservable> source, Func keySelector, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return new EditDiffChangeSetOptional(source, keySelector, equalityComparer).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.EnsureUniqueKeys.cs b/src/DynamicData/Cache/ObservableCacheEx.EnsureUniqueKeys.cs new file mode 100644 index 000000000..f3ec6e103 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.EnsureUniqueKeys.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Validates that each changeset contains no duplicate keys. + /// If duplicates are detected, an is emitted via OnError. + /// + /// The type of the object. + /// The type of the key. + /// The source to validate for unique keys. + /// A changeset stream guaranteed to contain unique keys per changeset. + /// + /// + /// EventBehavior + /// AddForwarded as Add if the key is unique within the changeset. + /// UpdateForwarded as Update if the key is unique within the changeset. + /// RemoveForwarded as Remove if the key is unique within the changeset. + /// RefreshForwarded as Refresh if the key is unique within the changeset. + /// OnErrorAlso emitted with if duplicate keys are detected in a changeset. + /// + /// + public static IObservable> EnsureUniqueKeys(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new UniquenessEnforcer(source).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Except.cs b/src/DynamicData/Cache/ObservableCacheEx.Except.cs new file mode 100644 index 000000000..19699c8d2 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Except.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Dynamically apply a logical Except operator between the collections + /// Items from the first collection in the outer list are included unless contained in any of the other lists. + /// + /// The type of the object. + /// The type of the key. + /// The source to combine. + /// The additional streams to combine with. + /// An observable which emits change sets. + /// + /// source + /// or + /// others. + /// + /// + public static IObservable> Except(this IObservable> source, params IObservable>[] others) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (others is null || others.Length == 0) + { + throw new ArgumentNullException(nameof(others)); + } + + return source.Combine(CombineOperator.Except, others); + } + + /// + /// Dynamically apply a logical Except operator between the collections + /// Items from the first collection in the outer list are included unless contained in any of the other lists. + /// + /// The type of the object. + /// The type of the key. + /// The of streams to combine. + /// An observable which emits change sets. + /// + /// source + /// or + /// others. + /// + public static IObservable> Except(this ICollection>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Except); + } + + /// + /// Dynamically apply a logical Except operator between the collections + /// Items from the first collection in the outer list are included unless contained in any of the other lists. + /// + /// The type of the object. + /// The type of the key. + /// The of streams to combine. + /// An observable which emits change sets. + public static IObservable> Except(this IObservableList>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Except); + } + + /// + /// Dynamically apply a logical Except operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits change sets. + public static IObservable> Except(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Except); + } + + /// + /// Dynamically apply a logical Except operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits change sets. + public static IObservable> Except(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Except); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ExpireAfter.cs b/src/DynamicData/Cache/ObservableCacheEx.ExpireAfter.cs new file mode 100644 index 000000000..168d8903e --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ExpireAfter.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Schedules automatic removal of items after the timeout returned by . + /// If returns , the item never expires. + /// + /// The type of the object. + /// The type of the key. + /// The source to apply time-based expiration to. + /// An optional that returns the expiration timeout for each item, or for no expiration. + /// An observable changeset that includes timer-driven Remove changes for expired items. + /// + /// When a timer fires, a Remove is emitted for the expired item. + /// + /// EventBehavior + /// AddSchedules a removal timer based on . Forwarded as Add. + /// UpdateResets the removal timer for the item. Forwarded as Update. + /// RemoveCancels the removal timer. Forwarded as Remove. + /// RefreshForwarded as Refresh. No timer change. + /// OnErrorAll pending timers are cancelled. + /// OnCompletedAll pending timers are cancelled. + /// + /// Worth noting: A return from means "never expire". Update changes reset the expiration timer. + /// + /// or is . + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector); + + /// + /// The source to apply time-based expiration to. + /// An optional that returns the expiration timeout for each item, or for no expiration. + /// The used to schedule expiration timers. + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector, + IScheduler scheduler) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector, + scheduler: scheduler); + + /// + /// The source to apply time-based expiration to. + /// An optional that returns the expiration timeout for each item, or for no expiration. + /// An optional polling interval. If specified, items are expired on a polling interval rather than per-item timers. Less accurate but more efficient when many items share similar expiration times. + /// + /// This overload uses periodic polling instead of per-item timers. Expired items are removed on the next + /// poll after their timeout elapses, which trades accuracy for reduced timer overhead. + /// + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector, + TimeSpan? pollingInterval) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval); + + /// + /// The source to apply time-based expiration to. + /// An optional that returns the expiration timeout for each item, or for no expiration. + /// An optional if specified, items are expired on a polling interval rather than per-item timers. + /// The used to schedule polling and expiration timers. + public static IObservable> ExpireAfter( + this IObservable> source, + Func timeSelector, + TimeSpan? pollingInterval, + IScheduler scheduler) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForStream.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); + + /// + /// Automatically removes items from the after the timeout returned + /// by . Returns an observable of the removed key-value pairs (not a changeset stream). + /// + /// The type of the object. + /// The type of the key. + /// The to operate on. + /// An optional that returns the expiration timeout for each item, or for no expiration. + /// An optional if specified, items are expired on a polling interval rather than per-item timers. + /// The scheduler used to schedule expiration timers. Defaults to if . + /// An observable that emits the key-value pairs of items removed from the cache by expiration. + /// + /// Unlike the stream-based overloads, this operates directly on the + /// and returns the removed items as collections, + /// not as a changeset stream. + /// + /// or is . + public static IObservable>> ExpireAfter( + this ISourceCache source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + => Cache.Internal.ExpireAfter.ForSource.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Filter.cs b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs new file mode 100644 index 000000000..7b43d0173 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs @@ -0,0 +1,145 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Filters items from the source changeset stream using a static predicate. + /// Only items that satisfy are included downstream. + /// + /// The type of the object. + /// The type of the key. + /// The source to filter. + /// The predicate used to determine whether each item is included. + /// When (default), empty changesets are suppressed for performance. Set to to emit empty changesets, which can be useful for monitoring loading status. + /// An observable changeset containing only items that satisfy . + /// + /// + /// EventBehavior + /// AddThe predicate is evaluated. If it passes, an Add is emitted. Otherwise the item is dropped. + /// UpdateFour outcomes: if both old and new values pass, an Update is emitted. If only the new value passes, an Add is emitted. If only the old value passed, a Remove is emitted. If neither passes, the change is dropped. + /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. + /// RefreshThe predicate is re-evaluated. If the item now passes but previously did not, an Add is emitted. If it still passes, a Refresh is forwarded. If it no longer passes, a Remove is emitted. If it still fails, the change is dropped. + /// + /// Worth noting: Refresh events trigger re-evaluation, which can promote or demote items. Pair with for property-change-driven filtering. + /// + /// + /// + /// + public static IObservable> Filter( + this IObservable> source, + Func filter, + bool suppressEmptyChangeSets = true) + where TObject : notnull + where TKey : notnull + => Cache.Internal.Filter.Static.Create( + source: source, + filter: filter, + suppressEmptyChangeSets: suppressEmptyChangeSets); + + /// + /// + /// This overload does not accept a reapplyFilter signal. It is equivalent to calling the + /// full dynamic overload with as the reapply observable. + /// + public static IObservable> Filter( + this IObservable> source, + IObservable> predicateChanged, + bool suppressEmptyChangeSets = true) + where TObject : notnull + where TKey : notnull + => source.Filter( + predicateChanged: predicateChanged, + reapplyFilter: Observable.Empty(), + suppressEmptyChangeSets: suppressEmptyChangeSets); + + /// + /// Creates a dynamically filtered stream where the filter predicate depends on external state. + /// Each emission from triggers a full re-filtering of all items. + /// + /// The type of the object. + /// The type of the key. + /// The type of state value required by . + /// The source to filter. + /// The stream of state values to be passed to . + /// The predicate that receives the current state and an item, returning to include or to exclude. + /// When (default), empty changesets are suppressed for performance. Set to to emit empty changesets. + /// An observable changeset containing only items satisfying for the latest state. + /// , , or is . + /// + /// + /// should emit an initial value immediately upon subscription. + /// Until the first state value arrives, no items pass the filter (all items are excluded). + /// Each subsequent state emission triggers a full re-evaluation of every item in the collection. + /// + /// + /// EventBehavior + /// AddEvaluated against the current state. If it passes, an Add is emitted. Otherwise dropped. + /// UpdateRe-evaluated. Four outcomes as with the static overload. + /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. + /// RefreshRe-evaluated against the current state. May produce Add, Refresh, Remove, or be dropped. + /// + /// Worth noting: should emit an initial value immediately. Each emission triggers a full re-evaluation of all items, which can be expensive for large collections. + /// + public static IObservable> Filter( + this IObservable> source, + IObservable predicateState, + Func predicate, + bool suppressEmptyChangeSets = true) + where TObject : notnull + where TKey : notnull + => Cache.Internal.Filter.Dynamic.Create( + source: source, + predicateState: predicateState, + predicate: predicate, + reapplyFilter: Observable.Empty(), + suppressEmptyChangeSets: suppressEmptyChangeSets); + + /// + /// The source to filter. + /// The that emits new predicates. Each emission replaces the current predicate and triggers a full re-evaluation of all items. + /// The that, when it emits, triggers a full re-evaluation of all items against the current predicate. Useful when filtering on mutable item properties. + /// When (default), empty changesets are suppressed for performance. + /// + /// In addition to the per-item behavior described in the static overload, + /// emissions from replace the predicate and trigger full re-filtering, + /// while emissions from re-evaluate all items against the current predicate. + /// Worth noting: No items are included until the predicate observable emits its first value. + /// + public static IObservable> Filter( + this IObservable> source, + IObservable> predicateChanged, + IObservable reapplyFilter, + bool suppressEmptyChangeSets = true) + where TObject : notnull + where TKey : notnull + + => Cache.Internal.Filter.Dynamic>.Create( + source: source, + predicateState: predicateChanged, + predicate: static (predicate, item) => predicate.Invoke(item), + reapplyFilter: reapplyFilter, + suppressEmptyChangeSets: suppressEmptyChangeSets); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.FilterImmutable.cs b/src/DynamicData/Cache/ObservableCacheEx.FilterImmutable.cs new file mode 100644 index 000000000..3c4d432a7 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.FilterImmutable.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Creates a filtered stream, optimized for stateless/deterministic filtering of immutable items. + /// + /// The type of collection items to be filtered. + /// The type of the key values of each collection item. + /// The source to filter (items assumed immutable). + /// The filtering predicate to be applied to each item. + /// A flag indicating whether the created stream should emit empty changesets. Empty changesets are suppressed by default, for performance. Set to ensure that a downstream changeset occurs for every upstream changeset. + /// A stream of collection changesets where upstream collection items are filtered by the given predicate. + /// + /// The goal of this operator is to optimize a common use-case of reactive programming, where data values flowing through a stream are immutable, and state changes are distributed by publishing new immutable items as replacements, instead of mutating the items directly. + /// In addition to assuming that all collection items are immutable, this operator also assumes that the given filter predicate is deterministic, such that the result it returns will always be the same each time a specific input is passed to it. In other words, the predicate itself also contains no mutable state. + /// Under these assumptions, this operator can bypass the need to keep track of every collection item that passes through it, which the normal operator must do, in order to re-evaluate the filtering status of items, during a refresh operation. + /// Consider using this operator when the following are true: + /// + /// Your collection items are immutable, and changes are published by replacing entire items + /// Your filtering logic does not change over the lifetime of the stream, only the items do + /// Your filtering predicate runs quickly, and does not heavily allocate memory + /// + /// Note that, because filtering is purely deterministic, Refresh operations are transparently ignored by this operator. + /// + /// EventBehavior + /// AddThe predicate is evaluated. If it passes, an Add is emitted. Otherwise the item is dropped. + /// UpdateFour outcomes: if both old and new values pass, an Update is emitted. If only the new value passes, an Add is emitted. If only the old value passed, a Remove is emitted. If neither passes, the change is dropped. + /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. + /// RefreshDropped. Because items are assumed immutable, there is nothing to re-evaluate. + /// + /// + public static IObservable> FilterImmutable( + this IObservable> source, + Func predicate, + bool suppressEmptyChangeSets = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); + + return new FilterImmutable( + predicate: predicate, + source: source, + suppressEmptyChangeSets: suppressEmptyChangeSets) + .Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.FilterOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.FilterOnObservable.cs new file mode 100644 index 000000000..82733f25a --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.FilterOnObservable.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Filters items using a per-item that controls inclusion. + /// Each item's observable is created by and toggles the item in or out of the downstream stream. + /// + /// The type of the object. + /// The type of the key. + /// The source to filter using per-item observables. + /// A factory that creates an for each item and its key. When the observable emits , the item is included; when , it is excluded. + /// A that optional time window to buffer inclusion changes from per-item observables before re-evaluating. + /// An that optional scheduler used for buffering. + /// An observable changeset containing only items whose per-item observable most recently emitted . + /// + /// + /// Source changeset handling (parent events): + /// + /// + /// EventBehavior + /// AddSubscribes to the per-item observable. The item is not included downstream until the observable emits its first . + /// UpdateDisposes the old item's observable subscription and subscribes to the new item's observable. Inclusion state is reset; the new observable must emit before the item reappears. + /// RemoveDisposes the item's observable subscription. If the item was included downstream, a Remove is emitted. + /// RefreshForwarded as Refresh if the item is currently included downstream. Otherwise dropped. + /// + /// + /// Per-item observable handling (filter observable events): + /// + /// + /// EmissionBehavior + /// First The item is included: an Add is emitted downstream. + /// (was included)The item is excluded: a Remove is emitted downstream. + /// (was excluded)The item is re-included: an Add is emitted downstream. + /// (was included)No effect (already included). + /// (was excluded)No effect (already excluded). + /// ErrorTerminates the entire output stream. + /// CompletedThe item remains in its current inclusion state. No further toggling is possible for this item. + /// + /// + /// Worth noting: Items are invisible downstream until their per-item observable emits at least one . + /// If an item's observable never emits, the item never appears. The parameter batches + /// rapid inclusion changes from per-item observables into a single re-evaluation, reducing changeset chatter. + /// + /// + /// or is . + /// + /// + public static IObservable> FilterOnObservable(this IObservable> source, Func> filterFactory, TimeSpan? buffer = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + filterFactory.ThrowArgumentNullExceptionIfNull(nameof(filterFactory)); + + return new FilterOnObservable(source, filterFactory, buffer, scheduler).Run(); + } + + /// + /// + /// This overload does not provide the key to ; only the item is passed. + /// + public static IObservable> FilterOnObservable(this IObservable> source, Func> filterFactory, TimeSpan? buffer = null, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + filterFactory.ThrowArgumentNullExceptionIfNull(nameof(filterFactory)); + + return source.FilterOnObservable((obj, _) => filterFactory(obj), buffer, scheduler); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.FinallySafe.cs b/src/DynamicData/Cache/ObservableCacheEx.FinallySafe.cs new file mode 100644 index 000000000..b50111d6b --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.FinallySafe.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Obsolete: do not use. This can cause unhandled exception issues. Use the standard Rx Finally operator instead. + /// + /// The type contained within the observables. + /// The source to attach a finally action to. + /// The to invoke when the subscription terminates. + /// An observable which has always a finally action applied. + [Obsolete("This can cause unhandled exception issues so do not use")] + public static IObservable FinallySafe(this IObservable source, Action finallyAction) + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + finallyAction.ThrowArgumentNullExceptionIfNull(nameof(finallyAction)); + + return new FinallySafe(source, finallyAction).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Flatten.cs b/src/DynamicData/Cache/ObservableCacheEx.Flatten.cs new file mode 100644 index 000000000..23052c9e5 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Flatten.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Unwraps each into individual + /// values via . + /// + /// The type of the object. + /// The type of the key. + /// The source to flatten into individual changes. + /// An observable of individual values. + /// is . + /// + public static IObservable> Flatten(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.SelectMany(changes => changes); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.FlattenBufferResult.cs b/src/DynamicData/Cache/ObservableCacheEx.FlattenBufferResult.cs new file mode 100644 index 000000000..357b859ac --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.FlattenBufferResult.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Merges a list of changesets (typically from an Rx Buffer operation) into a single changeset + /// by concatenating all changes. Empty buffers are filtered out. + /// + /// The type of the object. + /// The type of the key. + /// The source to flatten. + /// An observable changeset combining all changes from each buffer into a single emission. + /// is . + public static IObservable> FlattenBufferResult(this IObservable>> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Where(x => x.Count != 0).Select(updates => new ChangeSet(updates.SelectMany(u => u))); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ForEachChange.cs b/src/DynamicData/Cache/ObservableCacheEx.ForEachChange.cs new file mode 100644 index 000000000..fb23c04b6 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ForEachChange.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Invokes for every individual in each changeset, + /// regardless of change reason. The changeset is forwarded downstream unchanged. + /// + /// The type of the object. + /// The type of the key. + /// The source to observe each individual change in. + /// The action to invoke for each change. Receives the full struct, including , , , and . + /// A stream that forwards all changesets from unchanged. + /// + /// + /// All change reasons (Add, Update, Remove, Refresh) trigger the callback. + /// Use , + /// , + /// , or + /// + /// to target a specific reason. + /// + /// + /// Implemented via Rx's Do operator on the changeset stream. + /// Exceptions thrown in propagate as OnError to the subscriber. No try-catch is applied. + /// + /// + /// or is . + /// + public static IObservable> ForEachChange(this IObservable> source, Action> action) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + action.ThrowArgumentNullExceptionIfNull(nameof(action)); + + return source.Do(changes => changes.ForEach(action)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ForForced.cs b/src/DynamicData/Cache/ObservableCacheEx.ForForced.cs new file mode 100644 index 000000000..02103498f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ForForced.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + private static IObservable>? ForForced(this IObservable? source) + where TKey : notnull => source?.Select( + _ => + { + static bool Transformer(TSource item, TKey key) => true; + return (Func)Transformer; + }); + + private static IObservable>? ForForced(this IObservable>? source) + where TKey : notnull => source?.Select( + condition => + { + bool Transformer(TSource item, TKey key) => condition(item); + return (Func)Transformer; + }); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.FullJoin.cs b/src/DynamicData/Cache/ObservableCacheEx.FullJoin.cs new file mode 100644 index 000000000..9328527e2 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.FullJoin.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the optional left and right values into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> FullJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, Optional, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.FullJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Joins two changeset streams, producing a result for every key that appears on either side (or both). + /// Both sides are because a given key may only exist on one side at any point. + /// Equivalent to SQL FULL OUTER JOIN. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the key, optional left, and optional right into a destination object. Example: (key, left, right) => new Result(key, left, right). + /// An observable changeset keyed by . + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddEmits with the left value and the matching right (or if no right exists). + /// UpdateRe-invokes with the new left value and current right (if any). + /// RemoveIf a right match still exists, re-invokes the selector with left as . If neither side remains, removes the joined result. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddEmits with the matching left (or ) and the right value. + /// UpdateRe-invokes selector with current left (if any) and the new right value. + /// RemoveIf a left match still exists, re-invokes the selector with right as . If neither side remains, removes the joined result. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> FullJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, Optional, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new FullJoin(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.FullJoinMany.cs b/src/DynamicData/Cache/ObservableCacheEx.FullJoinMany.cs new file mode 100644 index 000000000..c2e5894bc --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.FullJoinMany.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the optional left value and the right group into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> FullJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.FullJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Groups right-side items by their mapped key, then full-joins each group to the left source. + /// A result is produced for every key that appears on either side (or both). The left value is + /// because only the right side may have entries for a given key. + /// Equivalent to SQL FULL OUTER JOIN with the right side grouped. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the key, optional left value, and the right group into a destination object. Example: (key, left, group) => new Result(key, left, group). + /// An observable changeset keyed by . + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddEmits with the left value and the current right group for that key (may be empty). + /// UpdateRe-invokes with the new left value and current right group. + /// RemoveIf the right group is non-empty, re-invokes with left as . If both sides are empty, removes the result. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddUpdates the right group, then re-invokes selector with the current left (if any) and the updated group. + /// UpdateUpdates the right group and re-invokes selector. + /// RemoveUpdates the right group. If the group becomes empty and no left exists, removes the result. Otherwise re-invokes selector. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> FullJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new FullJoinMany(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Group.cs b/src/DynamicData/Cache/ObservableCacheEx.Group.cs new file mode 100644 index 000000000..b803146d8 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Group.cs @@ -0,0 +1,166 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Groups items from the source changeset, producing groups only for group keys present in . + /// Useful for parent-child relationships where parents and children come from different streams. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group. + /// The group selector factory. + /// An of used to determine which groups appear in the result. + /// + /// Useful for parent-child collection when the parent and child are soured from different streams. + /// + /// An observable which will emit group change sets. + public static IObservable> Group(this IObservable> source, Func groupSelector, IObservable> resultGroupSource) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + groupSelector.ThrowArgumentNullExceptionIfNull(nameof(groupSelector)); + resultGroupSource.ThrowArgumentNullExceptionIfNull(nameof(resultGroupSource)); + + return new SpecifiedGrouper(source, groupSelector, resultGroupSource).Run(); + } + + /// + /// Groups items from the source changeset by a key extracted via . + /// Each group is an observable sub-cache that receives changes for its members. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group. + /// A that extracts the group key from each item. + /// An observable that emits group changesets. Each group exposes a sub-cache of its members. + /// + /// + /// Items are assigned to groups based on the value returned by . + /// Groups are created on demand when the first item is assigned, and removed when their last member is removed. + /// + /// + /// EventBehavior + /// AddThe group key is evaluated. The item is added to the corresponding group (creating the group if new). An Add is emitted to the group's sub-cache. + /// UpdateThe group key is re-evaluated. If unchanged, an Update is emitted within the same group. If the key changed, the item is removed from the old group (emitting Remove) and added to the new group (emitting Add). An empty old group is removed. + /// RemoveThe item is removed from its group. If the group becomes empty, the group itself is removed from the output. + /// RefreshThe group key is re-evaluated. If unchanged, a Refresh is forwarded within the group. If the key changed, the item moves between groups (Remove from old, Add to new). + /// + /// + /// Worth noting: Each group is a live sub-cache that can be subscribed to independently. Subscribers + /// to a group receive only changes for items in that group. When a group is removed (becomes empty), + /// its sub-cache completes. + /// + /// + /// + /// + /// + public static IObservable> Group(this IObservable> source, Func groupSelectorKey) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); + + return new GroupOn(source, groupSelectorKey, null).Run(); + } + + /// + /// The source to group. + /// A that extracts the group key from each item. + /// An that, when it emits, all items are re-evaluated against the group selector, potentially moving items between groups. + /// An observable that emits group changesets. + /// This overload adds a signal. When it fires, every item in the cache is re-grouped using the current selector, which is useful when the grouping depends on mutable item state. + public static IObservable> Group(this IObservable> source, Func groupSelectorKey, IObservable regrouper) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); + regrouper.ThrowArgumentNullExceptionIfNull(nameof(regrouper)); + + return new GroupOn(source, groupSelectorKey, regrouper).Run(); + } + + /// + /// Groups items using a dynamically changing group selector function. + /// Each time emits a new selector, all items are re-grouped. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group. + /// The that emits group selector functions. Each emission triggers a full re-grouping of all items. + /// An that optional signal to force re-evaluation of all items against the current selector. + /// An observable that emits group changesets. + /// + /// + /// Unlike the static-selector overload, this accepts an observable of selector functions. When a new selector + /// arrives, every item is re-evaluated and may move between groups. The optional + /// signal triggers re-evaluation without changing the selector (useful when item properties that affect grouping change). + /// + /// + /// EventBehavior + /// AddThe current selector determines the group. Item is added to the group (group created if new). + /// UpdateGroup key re-evaluated. Item may move between groups if the key changed. + /// RemoveItem removed from its group. Empty groups are removed. + /// RefreshGroup key re-evaluated. Item may move between groups. + /// + /// + /// + /// + public static IObservable> Group(this IObservable> source, IObservable> groupSelectorKeyObservable, IObservable? regrouper = null) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + groupSelectorKeyObservable.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKeyObservable)); + + return new GroupOnDynamic(source, groupSelectorKeyObservable, regrouper).Run(); + } + + /// + /// The source to group. + /// The of selector functions that take only the item (not the key). + /// An optional signal to force re-evaluation. + /// This overload accepts a selector that does not receive the key. Delegates to the overload accepting Func<TObject, TKey, TGroupKey>. + public static IObservable> Group(this IObservable> source, IObservable> groupSelectorKeyObservable, IObservable? regrouper = null) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + groupSelectorKeyObservable.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKeyObservable)); + + return source.Group(groupSelectorKeyObservable.Select(AdaptSelector), regrouper); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.GroupOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.GroupOnObservable.cs new file mode 100644 index 000000000..31115d013 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.GroupOnObservable.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Groups items where each item's group key is determined by a per-item observable. + /// The observable is created by for each item. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group using per-item observables. + /// A factory that creates a group key observable for each item and its key. + /// An observable that emits group changesets. Each group is a live sub-cache of its members. + /// + /// + /// Unlike which evaluates + /// the group key synchronously, this operator defers group assignment until the per-item observable emits. + /// + /// + /// Source changeset handling (parent events): + /// + /// + /// EventBehavior + /// AddSubscribes to the per-item group key observable. The item is not placed in any group until the observable emits its first group key. + /// UpdateDisposes the old item's group key subscription and subscribes to the new item's observable. The item is removed from its current group until the new observable emits. + /// RemoveDisposes the item's group key subscription. The item is removed from its current group. Empty groups are removed. + /// RefreshNo effect on subscriptions. The item remains in its current group. + /// + /// + /// Per-item observable handling (group key observable events): + /// + /// + /// EmissionBehavior + /// First valueThe item is placed into the group matching the emitted key. An Add appears in that group's sub-cache. If the group is new, the group itself is added to the output. + /// New value (different key)The item moves: Remove from the old group, Add to the new group. If the old group becomes empty, it is removed from the output. + /// Same value (unchanged key)No effect (filtered by DistinctUntilChanged). + /// ErrorTerminates the entire output stream. + /// CompletedThe item remains in its current group. No further group key changes are possible for this item. + /// + /// + /// Worth noting: Items are invisible (not in any group) until their per-item observable emits at least one + /// group key. If an item's observable never emits, the item never appears in any group. Per-item observable errors + /// terminate the entire stream. The output completes when the source completes and all per-item observables have + /// also completed. + /// + /// + /// + /// + /// + /// + public static IObservable> GroupOnObservable(this IObservable> source, Func> groupObservableSelector) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + groupObservableSelector.ThrowArgumentNullExceptionIfNull(nameof(groupObservableSelector)); + + return new GroupOnObservable(source, groupObservableSelector).Run(); + } + + /// + /// Groups the source by the latest value from their observable created by the given factory. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group using per-item observables. + /// The group selector key. + /// An observable which will emit group change sets. + public static IObservable> GroupOnObservable(this IObservable> source, Func> groupObservableSelector) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + groupObservableSelector.ThrowArgumentNullExceptionIfNull(nameof(groupObservableSelector)); + + return source.GroupOnObservable(AdaptSelector>(groupObservableSelector)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.GroupOnProperty.cs b/src/DynamicData/Cache/ObservableCacheEx.GroupOnProperty.cs new file mode 100644 index 000000000..5165d4091 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.GroupOnProperty.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Groups the source using the property specified by the property selector. Groups are re-applied when the property value changed. + /// When there are likely to be a large number of group property changes specify a throttle to improve performance. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group by a property value. + /// The property selector used to group the items. + /// An optional a time span that indicates the throttle to wait for property change events. + /// An optional for scheduling work. + /// An observable which will emit immutable group change sets. + public static IObservable> GroupOnProperty(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + return new GroupOnProperty(source, propertySelector, propertyChangedThrottle, scheduler).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.GroupOnPropertyWithImmutableState.cs b/src/DynamicData/Cache/ObservableCacheEx.GroupOnPropertyWithImmutableState.cs new file mode 100644 index 000000000..2be1f5ca0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.GroupOnPropertyWithImmutableState.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Groups the source using the property specified by the property selector. Each update produces immutable grouping. Groups are re-applied when the property value changed. + /// When there are likely to be a large number of group property changes specify a throttle to improve performance. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group by a property value with immutable snapshots. + /// The property selector used to group the items. + /// An optional a time span that indicates the throttle to wait for property change events. + /// An optional for scheduling work. + /// An observable which will emit immutable group change sets. + public static IObservable> GroupOnPropertyWithImmutableState(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + return new GroupOnPropertyWithImmutableState(source, propertySelector, propertyChangedThrottle, scheduler).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.GroupWithImmutableState.cs b/src/DynamicData/Cache/ObservableCacheEx.GroupWithImmutableState.cs new file mode 100644 index 000000000..1284a4b4f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.GroupWithImmutableState.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Groups items by , emitting immutable group snapshots instead of mutable sub-caches. + /// Each group change contains a frozen copy of the group's state at that point in time. + /// + /// The type of the object. + /// The type of the key. + /// The type of the group key. + /// The source to group with immutable snapshots. + /// A that extracts the group key from each item. + /// An that optional signal to force re-evaluation of all items against the group selector. + /// An observable that emits immutable group changesets. + /// + /// + /// Behaves identically to + /// in terms of how items are assigned to groups, but each group emission is an immutable snapshot. + /// This makes it safe for parallel processing and eliminates race conditions on group state. + /// The tradeoff is higher memory usage, since each change produces a new snapshot of the affected group. + /// + /// + /// EventBehavior + /// AddItem added to its group. An immutable snapshot of the group is emitted. + /// UpdateIf group key unchanged, group snapshot re-emitted. If changed, item moves between groups; both affected groups emit new snapshots. + /// RemoveItem removed from group. Updated snapshot emitted. Empty groups are removed. + /// RefreshGroup key re-evaluated. If changed, item moves; affected group snapshots emitted. + /// + /// + /// + /// + public static IObservable> GroupWithImmutableState(this IObservable> source, Func groupSelectorKey, IObservable? regrouper = null) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); + + return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.IgnoreSameReferenceUpdate.cs b/src/DynamicData/Cache/ObservableCacheEx.IgnoreSameReferenceUpdate.cs new file mode 100644 index 000000000..f5ff65ace --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.IgnoreSameReferenceUpdate.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Ignores updates when the update is the same reference. + /// + /// The object of the change set. + /// The key of the change set. + /// The source to suppress same-reference updates in. + /// An observable which emits change sets and ignores equal value changes. + public static IObservable> IgnoreSameReferenceUpdate(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.IgnoreUpdateWhen((c, p) => ReferenceEquals(c, p)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.IgnoreUpdateWhen.cs b/src/DynamicData/Cache/ObservableCacheEx.IgnoreUpdateWhen.cs new file mode 100644 index 000000000..2b8f11a87 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.IgnoreUpdateWhen.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Ignores the update when the condition is met. + /// The first parameter in the ignore function is the current value and the second parameter is the previous value. + /// + /// The type of the object. + /// The type of the key. + /// The source to selectively suppress updates in. + /// The ignore function (current,previous)=>{ return true to ignore }. + /// An observable which emits change sets and ignores updates equal to the lambda. + public static IObservable> IgnoreUpdateWhen(this IObservable> source, Func ignoreFunction) + where TObject : notnull + where TKey : notnull => source.Select( + updates => + { + var result = updates.Where( + u => + { + if (u.Reason != ChangeReason.Update) + { + return true; + } + + return !ignoreFunction(u.Current, u.Previous.Value); + }); + return new ChangeSet(result); + }).NotEmpty(); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.IncludeUpdateWhen.cs b/src/DynamicData/Cache/ObservableCacheEx.IncludeUpdateWhen.cs new file mode 100644 index 000000000..2c1a2e020 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.IncludeUpdateWhen.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Only includes the update when the condition is met. + /// The first parameter in the ignore function is the current value and the second parameter is the previous value. + /// + /// The type of the object. + /// The type of the key. + /// The source to selectively include updates in. + /// The include function (current,previous)=>{ return true to include }. + /// An observable which emits change sets and ignores updates equal to the lambda. + public static IObservable> IncludeUpdateWhen(this IObservable> source, Func includeFunction) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + includeFunction.ThrowArgumentNullExceptionIfNull(nameof(includeFunction)); + + return source.Select( + changes => + { + var result = changes.Where(change => change.Reason != ChangeReason.Update || includeFunction(change.Current, change.Previous.Value)); + return new ChangeSet(result); + }).NotEmpty(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.InnerJoin.cs b/src/DynamicData/Cache/ObservableCacheEx.InnerJoin.cs new file mode 100644 index 000000000..8a80e640d --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.InnerJoin.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the left and right values into a destination object. The composite key is not provided in this overload. + /// Overload that omits the composite key from the result selector. Delegates to . + public static IObservable> InnerJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.InnerJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Joins two changeset streams, producing a result only for keys that exist on both sides simultaneously. + /// When either side loses its value for a key, the joined result is removed. Equivalent to SQL INNER JOIN. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the composite key, left value, and right value into a destination object. Example: ((leftKey, rightKey), left, right) => new Result(leftKey, rightKey, left, right). + /// An observable changeset keyed by a composite (TLeftKey, TRightKey) tuple. + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddIf a matching right value exists, invokes and emits an Add. If no right match, no emission. + /// UpdateIf a matching right exists, re-invokes the selector and emits an Update. + /// RemoveRemoves all joined results involving the removed left key. + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddIf a matching left value exists, invokes the selector and emits an Add. + /// UpdateIf a matching left exists, re-invokes the selector and emits an Update. + /// RemoveRemoves the joined result for this right key (if it was downstream). + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// The output is keyed by a (TLeftKey, TRightKey) composite tuple, since a single left item may match multiple right items. + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> InnerJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func<(TLeftKey leftKey, TRightKey rightKey), TLeft, TRight, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new InnerJoin(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.InnerJoinMany.cs b/src/DynamicData/Cache/ObservableCacheEx.InnerJoinMany.cs new file mode 100644 index 000000000..60bf2a343 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.InnerJoinMany.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the left value and the right group into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> InnerJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.InnerJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Groups right-side items by their mapped key, then inner-joins each group to the left source. + /// A result is produced only when a left item and at least one right item share the same key. + /// Equivalent to SQL INNER JOIN with the right side grouped. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the key, left value, and right group into a destination object. Example: (key, left, group) => new Result(key, left, group). + /// An observable changeset keyed by . + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddIf a non-empty right group exists for this key, invokes and emits an Add. Otherwise no emission. + /// UpdateIf a right group exists, re-invokes the selector and emits an Update. + /// RemoveRemoves the joined result (if it was downstream). + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddUpdates the right group. If a matching left exists and the group was previously empty, emits an Add. If already joined, emits an Update. + /// UpdateUpdates the right group and re-invokes the selector if a matching left exists. + /// RemoveUpdates the right group. If the group becomes empty, removes the joined result. + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> InnerJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new InnerJoinMany(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.InvokeEvaluate.cs b/src/DynamicData/Cache/ObservableCacheEx.InvokeEvaluate.cs new file mode 100644 index 000000000..f19fde951 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.InvokeEvaluate.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Calls Evaluate() on items that implement when a Refresh change arrives. + /// Other change reasons are forwarded without invoking Evaluate. + /// + /// The type of the object. + /// The type of the key. + /// The source to trigger re-evaluation on. + /// An observable that emits the same changesets as , unchanged. + /// + /// + /// EventBehavior + /// AddForwarded unchanged. + /// UpdateForwarded unchanged. + /// RemoveForwarded unchanged. + /// RefreshCalls Evaluate() on the item, then forwards the change. + /// + /// + public static IObservable> InvokeEvaluate(this IObservable> source) + where TObject : IEvaluateAware + where TKey : notnull => source.Do(changes => changes.Where(u => u.Reason == ChangeReason.Refresh).ForEach(u => u.Current.Evaluate())); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.LeftJoin.cs b/src/DynamicData/Cache/ObservableCacheEx.LeftJoin.cs new file mode 100644 index 000000000..5da9d2abe --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.LeftJoin.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the left value and the optional right into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> LeftJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.LeftJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Joins two changeset streams, producing a result for every left-side key. The right side is + /// because a matching right item may or may not exist. All left items + /// appear in the output regardless. Equivalent to SQL LEFT OUTER JOIN. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the key, left value, and optional right into a destination object. Example: (key, left, right) => new Result(key, left, right). + /// An observable changeset keyed by . + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddAlways emits. Invokes with the left value and matching right (or ). + /// UpdateRe-invokes the selector with the new left value and current right (if any). + /// RemoveRemoves the joined result. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddIf a matching left exists, re-invokes the selector (right transitions from None to Some) and emits an Update. + /// UpdateIf a matching left exists, re-invokes the selector with the new right value. + /// RemoveIf a matching left exists, re-invokes the selector (right transitions from Some to None) and emits an Update. + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> LeftJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new LeftJoin(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.LeftJoinMany.cs b/src/DynamicData/Cache/ObservableCacheEx.LeftJoinMany.cs new file mode 100644 index 000000000..726ec5f1f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.LeftJoinMany.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the left value and the right group into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> LeftJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.LeftJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Groups right-side items by their mapped key, then left-joins each group to the left source. + /// A result is produced for every left-side key. The right group may be empty if no right items match. + /// Equivalent to SQL LEFT OUTER JOIN with the right side grouped. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the key, left value, and right group into a destination object. Example: (key, left, group) => new Result(key, left, group). + /// An observable changeset keyed by . + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddAlways emits. Invokes with the left value and the current right group (which may be empty). + /// UpdateRe-invokes the selector with the new left value and current right group. + /// RemoveRemoves the joined result. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddUpdates the right group. If a matching left exists, re-invokes the selector and emits an Update. + /// UpdateUpdates the right group and re-invokes the selector if a matching left exists. + /// RemoveUpdates the right group. If a matching left exists, re-invokes the selector (group may now be empty). + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> LeftJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new LeftJoinMany(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.LimitSizeTo.cs b/src/DynamicData/Cache/ObservableCacheEx.LimitSizeTo.cs new file mode 100644 index 000000000..810f118d0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.LimitSizeTo.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Applies a FIFO size limit to the changeset stream. When the number of items exceeds , + /// the oldest items are evicted and emitted as Remove changes. + /// + /// The type of the object. + /// The type of the key. + /// The source to apply size limits to. + /// The maximum number of items allowed. Must be greater than zero. + /// An observable changeset stream with size-limited contents. + /// + /// + /// EventBehavior + /// AddForwarded. If the cache exceeds the size limit, the oldest items are emitted as Remove changes. + /// UpdateForwarded unchanged. + /// RemoveForwarded unchanged. + /// RefreshForwarded unchanged. + /// + /// + /// is . + /// is zero or negative. + public static IObservable> LimitSizeTo(this IObservable> source, int size) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (size <= 0) + { + throw new ArgumentException("Size limit must be greater than zero"); + } + + return new SizeExpirer(source, size).Run(); + } + + /// + /// Operates directly on a , removing the oldest items when the cache + /// exceeds . Returns an observable of the evicted key-value pairs (not a changeset stream). + /// + /// The type of the object. + /// The type of the key. + /// The to operate on. + /// The maximum number of items allowed. Must be greater than zero. + /// An optional for observing changes. Defaults to . + /// An observable that emits batches of evicted key-value pairs whenever the cache exceeds the size limit. + /// is . + /// is zero or negative. + public static IObservable>> LimitSizeTo(this ISourceCache source, int sizeLimit, IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (sizeLimit <= 0) + { + throw new ArgumentException("Size limit must be greater than zero", nameof(sizeLimit)); + } + + return Observable.Create>>( + observer => + { + long orderItemWasAdded = -1; + var sizeLimiter = new SizeLimiter(sizeLimit); + + return source.Connect().Finally(observer.OnCompleted).ObserveOn(scheduler ?? GlobalConfig.DefaultScheduler).Transform((t, v) => new ExpirableItem(t, v, DateTime.Now, Interlocked.Increment(ref orderItemWasAdded))).Select(sizeLimiter.CloneAndReturnExpiredOnly).Where(expired => expired.Length != 0).Subscribe( + toRemove => + { + try + { + source.Remove(toRemove.Select(kv => kv.Key)); + observer.OnNext(toRemove); + } + catch (Exception ex) + { + observer.OnError(ex); + } + }); + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeChangeSets.cs new file mode 100644 index 000000000..c29bd2a6b --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeChangeSets.cs @@ -0,0 +1,434 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Merges multiple changeset streams that arrive dynamically into a single unified changeset stream. + /// Each inner stream emitted by the outer observable is subscribed and its changes forwarded downstream. + /// When multiple sources provide the same key, the first source to add it retains priority unless a + /// comparer-based overload is used. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// An that emits changeset streams. Each inner stream is subscribed as it appears. + /// A unified changeset stream containing changes from all active source streams. + /// + /// + /// Each inner changeset stream is independently tracked in its own cache. When multiple sources provide the same key, + /// this overload uses first-in-wins semantics: the value from whichever source added the key first is + /// the one published downstream. To control which value wins for duplicate keys, use an overload that + /// accepts an , which selects the lowest-ordered value across all sources. + /// An can be provided separately to suppress no-op updates when + /// the new value equals the currently published value for a key. + /// + /// + /// Overload families: MergeChangeSets has 16 overloads organized along three axes: + /// (1) Source type: dynamic (IObservable<IObservable<IChangeSet>>, sources arrive at runtime), + /// pair (source + other, exactly two streams), or static (, all sources known up front). + /// (2) Conflict resolution: none (first-in-wins), (lowest-ordered wins), + /// (suppresses duplicate updates), or both. + /// (3) Completion: static overloads accept a completable flag; when , the output never completes + /// even after all sources finish (useful for "live" merge scenarios). + /// + /// + /// EventBehavior + /// AddIf no source has previously provided this key, an Add is emitted downstream. If another source already holds this key, the new value is tracked internally but not emitted (first-in-wins). With a comparer, the lowest-ordered value across all sources is selected and published instead. + /// UpdateIf the updating source currently owns the downstream value for this key, an Update is emitted. If a comparer is provided and the update causes a different source's value to become the best candidate, an Update is emitted with that other source's value. + /// RemoveIf the removed value was the one published downstream, the operator scans all remaining sources for the same key. If another source still holds that key, an Update is emitted with the replacement value (selected by comparer if provided, otherwise the next available). If no other source holds the key, a Remove is emitted. + /// RefreshIf the refreshed item matches the currently published value, the Refresh is forwarded. With a comparer, all sources are re-evaluated first; if a different value now wins, an Update is emitted instead of the Refresh. + /// OnCompletedFor dynamic overloads, the output completes when the outer observable completes and all subscribed inner observables have also completed. For static overloads, completion depends on the completable parameter (default ). + /// + /// + /// Worth noting: When a source removes a key that was published downstream, the fallback to another + /// source's value is emitted as an Update (not an Add). This can be surprising if you expect + /// a Remove followed by an Add. Also, errors from any single inner source terminate the entire merged + /// stream, so consider error handling within individual sources if isolation is needed. + /// + /// + /// is . + /// + /// + /// + public static IObservable> MergeChangeSets(this IObservable>> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new MergeChangeSets(source, equalityComparer: null, comparer: null).Run(); + } + + /// + /// Merges dynamic cache changeset streams into a single output, using a comparer to resolve key conflicts. + /// When multiple sources provide the same key, the item ordering lowest according to + /// is published downstream. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// An that emits changeset streams. Each inner stream is subscribed as it appears. + /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. + /// A unified changeset stream containing changes from all active source streams. + /// or is null. + public static IObservable> MergeChangeSets(this IObservable>> source, IComparer comparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new MergeChangeSets(source, equalityComparer: null, comparer).Run(); + } + + /// + /// Merges dynamic cache changeset streams into a single output, using an equality comparer to suppress + /// redundant updates. When an incoming value for a key is equal (per ) + /// to the currently published value, the update is suppressed. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// An that emits changeset streams. Each inner stream is subscribed as it appears. + /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. + /// A unified changeset stream containing changes from all active source streams. + /// or is null. + public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer equalityComparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + + return new MergeChangeSets(source, equalityComparer, comparer: null).Run(); + } + + /// + /// Merges dynamic cache changeset streams into a single output, using both a comparer for key conflict resolution + /// and an equality comparer to suppress redundant updates. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// An that emits changeset streams. Each inner stream is subscribed as it appears. + /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. + /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. + /// A unified changeset stream containing changes from all active source streams. + /// , , or is null. + public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer equalityComparer, IComparer comparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new MergeChangeSets(source, equalityComparer, comparer).Run(); + } + + /// + /// Convenience overload that merges exactly two cache changeset streams into a single output. + /// Uses first-in-wins semantics for key conflicts. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The second to merge with . + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when both streams complete. If , the output never completes. + /// A unified changeset stream containing changes from both sources. + /// or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + other.ThrowArgumentNullExceptionIfNull(nameof(other)); + + return new[] { source, other }.MergeChangeSets(scheduler, completable); + } + + /// + /// Convenience overload that merges exactly two cache changeset streams, using a comparer for key conflict resolution. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The second to merge with . + /// An that comparer to determine which value wins when both sources provide the same key. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when both streams complete. If , the output never completes. + /// A unified changeset stream containing changes from both sources. + /// , , or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IComparer comparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + other.ThrowArgumentNullExceptionIfNull(nameof(other)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new[] { source, other }.MergeChangeSets(comparer, scheduler, completable); + } + + /// + /// Convenience overload that merges exactly two cache changeset streams, using an equality comparer to suppress redundant updates. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The second to merge with . + /// An that equality comparer to detect duplicate values for the same key. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when both streams complete. If , the output never completes. + /// A unified changeset stream containing changes from both sources. + /// , , or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer equalityComparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + other.ThrowArgumentNullExceptionIfNull(nameof(other)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + + return new[] { source, other }.MergeChangeSets(equalityComparer, scheduler, completable); + } + + /// + /// Convenience overload that merges exactly two cache changeset streams, using both a comparer and an equality comparer. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The second to merge with . + /// An that equality comparer to detect duplicate values for the same key. + /// An that comparer to determine which value wins when both sources provide the same key. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when both streams complete. If , the output never completes. + /// A unified changeset stream containing changes from both sources. + /// , , , or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer equalityComparer, IComparer comparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + other.ThrowArgumentNullExceptionIfNull(nameof(other)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new[] { source, other }.MergeChangeSets(equalityComparer, comparer, scheduler, completable); + } + + /// + /// Merges with additional changeset streams into a single output. + /// Uses first-in-wins semantics for key conflicts. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The additional streams to merge with . + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all streams complete. If , the output never completes. + /// A unified changeset stream containing changes from all sources. + /// or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.EnumerateOne().Concat(others).MergeChangeSets(scheduler, completable); + } + + /// + /// Merges with additional changeset streams, using a comparer for key conflict resolution. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The additional streams to merge with . + /// An that comparer to determine which value wins when multiple sources provide the same key. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all streams complete. If , the output never completes. + /// A unified changeset stream containing changes from all sources. + /// , , or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IComparer comparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.EnumerateOne().Concat(others).MergeChangeSets(comparer, scheduler, completable); + } + + /// + /// Merges with additional changeset streams, using an equality comparer to suppress redundant updates. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The additional streams to merge with . + /// An that equality comparer to detect duplicate values for the same key. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all streams complete. If , the output never completes. + /// A unified changeset stream containing changes from all sources. + /// , , or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer equalityComparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + + return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, scheduler, completable); + } + + /// + /// Merges with additional changeset streams, using both a comparer and an equality comparer. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// The additional streams to merge with . + /// An that equality comparer to detect duplicate values for the same key. + /// An that comparer to determine which value wins when multiple sources provide the same key. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all streams complete. If , the output never completes. + /// A unified changeset stream containing changes from all sources. + /// , , , or is null. + public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer equalityComparer, IComparer comparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, comparer, scheduler, completable); + } + + /// + /// Merges a fixed collection of cache changeset streams into a single unified output. All source streams are + /// subscribed when the output observable is subscribed to. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all source streams have completed. If , the output never completes. + /// A unified changeset stream containing changes from all source streams. + /// + /// + /// When multiple sources provide items with the same key, this overload uses first-in-wins semantics: + /// the first source to provide a key retains priority. Removing that source's item allows the next + /// available value for that key (if any) to surface. To control which value wins, use an overload + /// that accepts an . + /// + /// + /// An error from any source terminates the entire merged output. + /// + /// + /// is null. + public static IObservable> MergeChangeSets(this IEnumerable>> source, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new MergeChangeSets(source, equalityComparer: null, comparer: null, completable, scheduler).Run(); + } + + /// + /// Merges a fixed collection of cache changeset streams into a single output, using a comparer for key conflict + /// resolution. When multiple sources provide the same key, the item ordering lowest according to + /// is published downstream. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all source streams have completed. If , the output never completes. + /// A unified changeset stream containing changes from all source streams. + /// or is null. + public static IObservable> MergeChangeSets(this IEnumerable>> source, IComparer comparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new MergeChangeSets(source, equalityComparer: null, comparer, completable, scheduler).Run(); + } + + /// + /// Merges a fixed collection of cache changeset streams into a single output, using an equality comparer to + /// suppress redundant updates. When an incoming value for a key is equal (per ) + /// to the currently published value, the update is suppressed. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all source streams have completed. If , the output never completes. + /// A unified changeset stream containing changes from all source streams. + /// or is null. + public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer equalityComparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + + return new MergeChangeSets(source, equalityComparer, comparer: null, completable, scheduler).Run(); + } + + /// + /// Merges a fixed collection of cache changeset streams into a single output, using both a comparer for key + /// conflict resolution and an equality comparer to suppress redundant updates. + /// + /// The type of items in the changesets. + /// The type of the key identifying items. + /// The source to merge. + /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. + /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. + /// An optional used when subscribing to the source streams. + /// If (default), the output completes when all source streams have completed. If , the output never completes. + /// A unified changeset stream containing changes from all source streams. + /// , , or is null. + public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer equalityComparer, IComparer comparer, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new MergeChangeSets(source, equalityComparer, comparer, completable, scheduler).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs new file mode 100644 index 000000000..b3c290d34 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Subscribes to a child observable for each item in the source cache changeset stream and merges all child + /// emissions into a single . When an item is added, + /// creates its child subscription. When updated, the previous child subscription is disposed and a new one is created. + /// When removed, its child subscription is disposed. Refresh changes have no effect on subscriptions. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of values emitted by child observables. + /// The source whose items each produce an observable. + /// A factory function that produces a child observable for each source item. + /// An observable that emits values from all active child observables, interleaved by arrival order. + /// + /// + /// This operator does not produce changesets. It produces a flat stream of + /// values, similar to Rx SelectMany but lifecycle-aware: child subscriptions track items entering and + /// leaving the source cache. + /// + /// + /// EventBehavior + /// AddCalls to create a child observable and subscribes to it. Emissions from the child flow into the merged output. + /// UpdateDisposes the previous child subscription and creates a new one for the updated item. + /// RemoveDisposes the child subscription for the removed item. + /// RefreshNo effect on subscriptions. The child observable continues unchanged. + /// OnErrorErrors from child observables are silently swallowed (the child is unsubscribed). Errors from the source changeset stream terminate the merged output. + /// + /// Worth noting: The output is a plain , not a changeset stream. If you need merged changesets, use instead. + /// + /// or is null. + /// + /// + /// + /// + public static IObservable MergeMany(this IObservable> source, Func> observableSelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeMany(source, observableSelector).Run(); + } + + /// + /// The source whose items each produce an observable. + /// A factory function that receives both the item and its key, and returns a child observable. + public static IObservable MergeMany(this IObservable> source, Func> observableSelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeMany(source, observableSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs new file mode 100644 index 000000000..5fcbb9ed0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs @@ -0,0 +1,437 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child changes + /// into a single flattened output. This overload requires a comparer for resolving destination key conflicts. + /// The selector receives only the item, not its key. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child cache changeset stream. + /// An that comparer to resolve key conflicts when multiple child streams provide items with the same destination key. The lowest-ordered item wins. + /// A merged changeset stream containing items from all active child streams. + /// or is null. + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), comparer); + } + + /// + /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child changes + /// into a single flattened output. This overload requires a comparer for resolving destination key conflicts. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and its key, and returns a child cache changeset stream. + /// An that comparer to resolve key conflicts when multiple child streams provide items with the same destination key. The lowest-ordered item wins. + /// A merged changeset stream containing items from all active child streams. + /// , , or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.MergeManyChangeSets(observableSelector, equalityComparer: null, comparer: comparer); + } + + /// + /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child changes + /// into a single flattened output. The selector receives only the item, not its key. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child cache changeset stream. + /// An that optional equality comparer to suppress updates when the incoming child value equals the current value for a destination key. + /// An that optional comparer to resolve key conflicts when multiple child streams provide items with the same destination key. The lowest-ordered item wins. + /// A merged changeset stream containing items from all active child streams. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), equalityComparer, comparer); + } + + /// + /// For each item in the source cache, subscribes to a child changeset stream and merges all child + /// changes into a single flattened output stream. Child subscriptions track the parent item lifecycle: + /// created on Add, replaced on Update, disposed on Remove. + /// + /// The type of items in the source (parent) cache. + /// The type of the key identifying parent items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a parent item and its key, and returns a child cache changeset stream. Called once per parent Add/Update. + /// An that optional equality comparer to suppress no-op child updates. When a child key's new value equals the current value per this comparer, the update is not emitted. + /// An that optional comparer to resolve child key conflicts when multiple parents contribute children with the same destination key. The lowest-ordered child value wins. Without a comparer, the first parent to provide a key retains priority. + /// A merged changeset stream containing all child items from all active parent subscriptions. + /// + /// + /// This is the changeset-aware counterpart to . + /// Where MergeMany produces a flat IObservable<T>, MergeManyChangeSets produces an IObservable<IChangeSet> + /// that tracks the full lifecycle of child items, including key conflict resolution across parents. + /// + /// + /// Parent-side change handling (source changeset events): + /// + /// + /// EventBehavior + /// AddCalls with the new parent item to obtain a child changeset stream, then subscribes. As the child stream emits changesets, those child items are merged into the output. The downstream observer sees Add changes for each new child item. + /// UpdateDisposes the previous parent's child subscription (removing all of its contributed child items from the output as Remove changes), then creates a new child subscription for the updated parent. The new child's items appear as Add changes. + /// RemoveDisposes the parent's child subscription. All child items contributed by that parent are emitted as Remove changes in the output. If another parent also provides a child with the same destination key, that parent's value is promoted as an Update (not an Add). + /// RefreshNo effect on the child subscription. The parent's child stream continues unchanged. + /// + /// + /// Child-side change handling (changes arriving from child changeset streams): + /// + /// + /// EventBehavior + /// AddIf the destination key is new, an Add is emitted. If another parent already contributed a child with the same key, the conflict is resolved by (lowest wins) or first-in-wins if no comparer. The losing value is tracked internally but not emitted. + /// UpdateIf this parent currently owns the destination key downstream, an Update is emitted. With a comparer, all parents are re-evaluated for that key; a different parent's value may win, producing an Update to that value instead. + /// RemoveIf this parent's value was the one published downstream for that destination key, the operator scans other parents for the same key. If found, an Update is emitted with the replacement. If not, a Remove is emitted. + /// RefreshIf the child item is the one currently published downstream, the Refresh is forwarded. With a comparer, all parents are re-evaluated first; if a different value now wins, an Update is emitted instead. + /// + /// + /// Error and completion: + /// + /// + /// EventBehavior + /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. + /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. + /// + /// + /// Worth noting: When multiple parents contribute children with the same destination key, only one value is published + /// downstream at a time. The controls which value wins; without it, the first parent to add the key + /// retains priority. Removing a parent that owned a contested key causes the next-best value (per comparer or next available) + /// to surface as an Update, not an Add. The independently controls whether a child + /// Update for an already-published key is suppressed when the new value equals the old. + /// + /// + /// or is . + /// + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); + } + + /// + /// Source-priority variant of MergeManyChangeSets with a required . + /// Uses to resolve destination key conflicts by source priority. + /// The selector receives only the item, not its key. + /// Source priorities are always re-evaluated on Refresh (default behavior). + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. Lower-ordered source wins. + /// An that fallback comparer to resolve destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, DefaultResortOnSourceRefresh, equalityComparer: null, childComparer); + } + + /// + /// Source-priority variant of MergeManyChangeSets with a required . + /// Uses to resolve destination key conflicts by source priority. + /// Source priorities are always re-evaluated on Refresh (default behavior). + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and its key, and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. Lower-ordered source wins. + /// An that fallback comparer to resolve destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull => source.MergeManyChangeSets(observableSelector, sourceComparer, DefaultResortOnSourceRefresh, equalityComparer: null, childComparer); + + /// + /// Source-priority variant of MergeManyChangeSets with a required and + /// explicit control. The selector receives only the item. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. + /// If , a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. + /// An that fallback comparer to resolve destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, resortOnSourceRefresh, equalityComparer: null, childComparer); + } + + /// + /// Source-priority variant of MergeManyChangeSets with a required and + /// explicit control. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and its key, and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. + /// If , a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. + /// An that fallback comparer to resolve destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IComparer childComparer) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull => source.MergeManyChangeSets(observableSelector, sourceComparer, resortOnSourceRefresh, equalityComparer: null, childComparer); + + /// + /// Source-priority variant of MergeManyChangeSets. Uses to resolve + /// destination key conflicts. The selector receives only the item, not its key. + /// Source priorities are always re-evaluated on Refresh (default behavior). + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. + /// An that optional equality comparer to suppress updates when the incoming child value equals the current value. + /// An that optional fallback comparer for destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, DefaultResortOnSourceRefresh, equalityComparer, childComparer); + } + + /// + /// Source-priority variant of MergeManyChangeSets. Uses to resolve + /// destination key conflicts. Source priorities are always re-evaluated on Refresh (default behavior). + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and its key, and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. + /// An that optional equality comparer to suppress updates when the incoming child value equals the current value. + /// An that optional fallback comparer for destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull => source.MergeManyChangeSets(observableSelector, sourceComparer, DefaultResortOnSourceRefresh, equalityComparer, childComparer); + + /// + /// Source-priority variant of MergeManyChangeSets with full control over all conflict resolution parameters. + /// The selector receives only the item, not its key. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. + /// If , a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. + /// An that optional equality comparer to suppress updates when the incoming child value equals the current value. + /// An that optional fallback comparer for destination key conflicts when source items compare equal. + /// A merged changeset stream with conflicts resolved by source priority. + /// or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, resortOnSourceRefresh, equalityComparer, childComparer); + } + + /// + /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child + /// changes into a single flattened output. When multiple source items produce children with the same destination key, + /// determines which source has priority (the source ordering lower wins). + /// If sources compare equal, (if provided) breaks the tie. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child changeset streams. + /// The type of the key identifying child items. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and its key, and returns a child cache changeset stream. + /// An that comparer to prioritize between source items when their children produce the same destination key. Lower-ordered source wins. + /// If (default), a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. + /// An that optional equality comparer to suppress updates when the incoming child value equals the current value for a destination key. + /// An that optional fallback comparer to resolve destination key conflicts when source items compare equal. + /// A merged changeset stream containing items from all active child streams, with conflicts resolved by source priority. + /// + /// + /// The provides a layer of conflict resolution above the child values themselves. + /// This is useful when source items represent priority tiers (e.g., user settings overriding defaults). + /// + /// + /// Errors from child streams propagate to the output. An error from the source or any child terminates the merged output. + /// The output completes when the source completes and all active child streams have also completed. + /// + /// + /// , , or is null. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + sourceComparer.ThrowArgumentNullExceptionIfNull(nameof(sourceComparer)); + + return new MergeManyCacheChangeSetsSourceCompare(source, observableSelector, sourceComparer, equalityComparer, childComparer, resortOnSourceRefresh).Run(); + } + + /// + /// For each item in the source cache, subscribes to a child list changeset stream produced by + /// and merges all child changes into a single flattened list changeset output. + /// Child subscriptions follow the source item lifecycle: created on Add, replaced on Update, disposed on Remove. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child list changeset streams. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and its key, and returns a child list changeset stream. + /// An that optional equality comparer to detect duplicate items in the merged list output. + /// A merged list changeset stream containing items from all active child streams. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeManyListChangeSets(source, observableSelector, equalityComparer).Run(); + } + + /// + /// For each item in the source cache, subscribes to a child list changeset stream and merges all child changes + /// into a single flattened list changeset output. The selector receives only the item, not its key. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of items in the child list changeset streams. + /// The source whose items each produce a child changeset stream. + /// A factory function that receives a source item and returns a child list changeset stream. + /// An that optional equality comparer to detect duplicate items in the merged list output. + /// A merged list changeset stream containing items from all active child streams. + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + { + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + return source.MergeManyChangeSets((obj, _) => observableSelector(obj), equalityComparer); + } + + private const bool DefaultResortOnSourceRefresh = true; +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeManyItems.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyItems.cs new file mode 100644 index 000000000..b2581c6c8 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeManyItems.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Like , + /// but wraps each emitted value as an , pairing the source item + /// with the value it produced. This lets you identify which source item is responsible for each emission. + /// + /// The type of items in the source cache. + /// The type of the key identifying source cache items. + /// The type of values emitted by child observables. + /// The source whose items each produce an observable. + /// A factory function that produces a child observable for each source item. + /// An observable of pairing each emission with its source item. + /// or is null. + public static IObservable> MergeManyItems(this IObservable> source, Func> observableSelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeManyItems(source, observableSelector).Run(); + } + + /// + /// The source whose items each produce an observable. + /// A factory function that receives both the item and its key, and returns a child observable. + public static IObservable> MergeManyItems(this IObservable> source, Func> observableSelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeManyItems(source, observableSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.MonitorStatus.cs b/src/DynamicData/Cache/ObservableCacheEx.MonitorStatus.cs new file mode 100644 index 000000000..93abf0f92 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.MonitorStatus.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Monitors the source observable and emits values: Pending initially, + /// Loaded when the first value arrives, Errored on error, and Completed on completion. + /// This is not a changeset operator. + /// + /// The type of the source observable. + /// The source to monitor for connection status. + /// An observable that emits values reflecting the source's lifecycle. + /// is . + /// + public static IObservable MonitorStatus(this IObservable source) => new StatusMonitor(source).Run(); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.NotEmpty.cs b/src/DynamicData/Cache/ObservableCacheEx.NotEmpty.cs new file mode 100644 index 000000000..a267d1511 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.NotEmpty.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Filters out empty changesets from the stream. A thin wrapper around Where(changes => changes.Count != 0). + /// + /// The type of the object. + /// The type of the key. + /// The source to suppress empty changesets. + /// An observable that emits only non-empty changesets. + /// is . + /// + public static IObservable> NotEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Where(changes => changes.Count != 0); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.OfType.cs b/src/DynamicData/Cache/ObservableCacheEx.OfType.cs new file mode 100644 index 000000000..dd12ae230 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.OfType.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Filters and casts items in the changeset to . Items that are not of type + /// are excluded. Combines filter and transform in one step without an intermediate cache. + /// + /// The type of the objects in the source changeset. + /// The type of the key. + /// The destination type to filter and cast to. + /// The source to filter by type. + /// If , changesets that become empty after filtering are suppressed. + /// An observable changeset of items. + /// + /// + /// EventBehavior + /// AddIf the item is , cast and emit as Add. Otherwise dropped. + /// UpdateRe-evaluated. If the new item is , emit accordingly. If the old item was downstream but the new one is not, emit Remove. + /// RemoveIf the item was downstream, emit Remove. + /// RefreshIf the item is downstream, forwarded as Refresh. + /// + /// + /// is . + public static IObservable> OfType(this IObservable> source, bool suppressEmptyChangeSets = true) + where TObject : notnull + where TKey : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new OfType(source, suppressEmptyChangeSets).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.OnChangeAction.cs b/src/DynamicData/Cache/ObservableCacheEx.OnChangeAction.cs new file mode 100644 index 000000000..00b222a68 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.OnChangeAction.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + private static IObservable> OnChangeAction(this IObservable> source, Predicate> predicate, Action> changeAction) + where TObject : notnull + where TKey : notnull + { + return source.Do(changes => + { + foreach (var change in changes.ToConcreteType()) + { + if (!predicate(change)) + { + continue; + } + + changeAction(change); + } + }); + } + + private static IObservable> OnChangeAction(this IObservable> source, ChangeReason reason, Action action) + where TObject : notnull + where TKey : notnull + => source.OnChangeAction(change => change.Reason == reason, change => action(change.Current, change.Key)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.OnItemAdded.cs b/src/DynamicData/Cache/ObservableCacheEx.OnItemAdded.cs new file mode 100644 index 000000000..40e47a95b --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.OnItemAdded.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Callback for each item as and when it is being added to the stream. + /// + /// The type of the object. + /// The type of the key. + /// The source to observe item additions in. + /// The callback invoked for each added item. Receives the new item and its key. + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddInvokes with the item and key. + /// UpdateIgnored. + /// RemoveIgnored. + /// RefreshIgnored. + /// + /// + /// + /// Exceptions thrown in propagate as OnError. No try-catch is applied. + /// + /// + /// or is . + /// + /// + /// + /// + public static IObservable> OnItemAdded(this IObservable> source, Action addAction) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + addAction.ThrowArgumentNullExceptionIfNull(nameof(addAction)); + + return source.OnChangeAction(ChangeReason.Add, addAction); + } + + /// + /// The source to observe item additions in. + /// The callback invoked for each added item. Receives only the item (no key). + /// Overload that omits the key from the callback. Delegates to . + public static IObservable> OnItemAdded(this IObservable> source, Action addAction) + where TObject : notnull + where TKey : notnull + => source.OnItemAdded((obj, _) => addAction(obj)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.OnItemRefreshed.cs b/src/DynamicData/Cache/ObservableCacheEx.OnItemRefreshed.cs new file mode 100644 index 000000000..13441fd02 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.OnItemRefreshed.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Callback for each item as and when it is being refreshed in the stream. + /// + /// The type of the object. + /// The type of the key. + /// The source to observe item refresh events in. + /// The callback invoked for each refreshed item. Receives the item and its key. + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddIgnored. + /// UpdateIgnored. + /// RemoveIgnored. + /// RefreshInvokes with the item and key. + /// + /// + /// + /// Exceptions thrown in propagate as OnError. No try-catch is applied. + /// + /// + /// or is . + /// + /// + public static IObservable> OnItemRefreshed(this IObservable> source, Action refreshAction) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + refreshAction.ThrowArgumentNullExceptionIfNull(nameof(refreshAction)); + + return source.OnChangeAction(ChangeReason.Refresh, refreshAction); + } + + /// + /// The source to observe item refresh events in. + /// The callback invoked for each refreshed item. Receives only the item (no key). + /// Overload that omits the key from the callback. Delegates to . + public static IObservable> OnItemRefreshed(this IObservable> source, Action refreshAction) + where TObject : notnull + where TKey : notnull + => source.OnItemRefreshed((obj, _) => refreshAction(obj)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.OnItemRemoved.cs b/src/DynamicData/Cache/ObservableCacheEx.OnItemRemoved.cs new file mode 100644 index 000000000..6935103fe --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.OnItemRemoved.cs @@ -0,0 +1,92 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Invokes for each item with in the changeset stream. + /// The changeset is forwarded downstream unchanged. + /// + /// The type of the object. + /// The type of the key. + /// The source to observe item removals in. + /// The callback invoked for each removed item. Receives the removed item and its key. + /// + /// When (the default), the callback is also invoked for every item still in the cache + /// when the subscription is disposed. When , only inline Remove changes trigger the callback. + /// + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddIgnored (but tracked internally when is ). + /// UpdateIgnored (cache updated internally when is ). + /// RemoveInvokes with the item and key. + /// RefreshIgnored. + /// + /// + /// + /// Unsubscribe behavior: when is , the operator + /// maintains an internal cache mirroring the stream. On disposal, it iterates all remaining items and + /// invokes for each. This is useful for cleanup logic (e.g. event unsubscription) + /// that must run for items that were never explicitly removed. + /// + /// + /// Exceptions thrown in propagate as OnError during inline removes. + /// During unsubscribe disposal, exceptions are not caught. + /// + /// Worth noting: The action also fires for ALL remaining items when the subscription is disposed (unless invokeOnUnsubscribe is ). The action runs under a lock; avoid calling into other caches from within it. + /// + /// or is . + /// + /// + /// + public static IObservable> OnItemRemoved(this IObservable> source, Action removeAction, bool invokeOnUnsubscribe = true) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + removeAction.ThrowArgumentNullExceptionIfNull(nameof(removeAction)); + + if (invokeOnUnsubscribe) + { + return new OnBeingRemoved(source, removeAction).Run(); + } + + return source.OnChangeAction(ChangeReason.Remove, removeAction); + } + + /// + /// The source to observe item removals in. + /// The callback invoked for each removed item. Receives only the item (no key). + /// When (the default), also invoked for all remaining items on disposal. + /// Overload that omits the key from the callback. Delegates to . + public static IObservable> OnItemRemoved(this IObservable> source, Action removeAction, bool invokeOnUnsubscribe = true) + where TObject : notnull + where TKey : notnull + => source.OnItemRemoved((obj, _) => removeAction(obj), invokeOnUnsubscribe); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.OnItemUpdated.cs b/src/DynamicData/Cache/ObservableCacheEx.OnItemUpdated.cs new file mode 100644 index 000000000..464bbf29b --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.OnItemUpdated.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Invokes for each item with in the changeset stream. + /// The changeset is forwarded downstream unchanged. + /// + /// The type of the object. + /// The type of the key. + /// The source to observe item updates in. + /// The callback invoked for each updated item. Receives the current value, previous value, and key. + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddIgnored. + /// UpdateInvokes with (current, previous, key). The previous value is always available for Update changes. + /// RemoveIgnored. + /// RefreshIgnored. + /// + /// + /// + /// Exceptions thrown in propagate as OnError. No try-catch is applied. + /// + /// + /// or is . + /// + /// + public static IObservable> OnItemUpdated(this IObservable> source, Action updateAction) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); + + return source.OnChangeAction(static change => change.Reason == ChangeReason.Update, change => updateAction(change.Current, change.Previous.Value, change.Key)); + } + + /// + /// The source to observe item updates in. + /// The callback invoked for each updated item. Receives only the current and previous values (no key). + /// Overload that omits the key from the callback. Delegates to . + public static IObservable> OnItemUpdated(this IObservable> source, Action updateAction) + where TObject : notnull + where TKey : notnull + => source.OnItemUpdated((cur, prev, _) => updateAction(cur, prev)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Or.cs b/src/DynamicData/Cache/ObservableCacheEx.Or.cs new file mode 100644 index 000000000..a0de6f338 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Or.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Combines multiple changeset streams using logical OR (union). An item appears downstream if it exists in any source. + /// + /// The type of the object. + /// The type of the key. + /// The source to combine. + /// The additional streams to combine with. + /// A changeset stream containing items present in any of the sources. + /// + /// + /// Items are tracked via reference counting across all sources. An item appears downstream as long as + /// at least one source contains it. When the last source holding a key removes it, the item is removed downstream. + /// + /// + /// EventBehavior + /// AddIf this is the first source to provide the key, an Add is emitted. If other sources already have the key, the reference count is incremented but no emission occurs. + /// UpdateIf the item is currently downstream, an Update is emitted. + /// RemoveReference count decremented. If the count reaches zero (no source holds the key), a Remove is emitted. Otherwise no emission. + /// RefreshIf the item is downstream, a Refresh is forwarded. + /// + /// + /// or is . + /// + /// + /// + /// + /// + public static IObservable> Or(this IObservable> source, params IObservable>[] others) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (others is null || others.Length == 0) + { + throw new ArgumentNullException(nameof(others)); + } + + return source.Combine(CombineOperator.Or, others); + } + + /// + /// The of streams to combine. + /// This overload accepts a pre-built collection of sources instead of a params array. + public static IObservable> Or(this ICollection>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Or); + } + + /// + /// Dynamically apply a logical Or operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of streams to combine. + /// An observable which emits change sets. + public static IObservable> Or(this IObservableList>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Or); + } + + /// + /// Dynamically apply a logical Or operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits change sets. + public static IObservable> Or(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Or); + } + + /// + /// Dynamically apply a logical Or operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits change sets. + public static IObservable> Or(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Or); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.PopulateFrom.cs b/src/DynamicData/Cache/ObservableCacheEx.PopulateFrom.cs new file mode 100644 index 000000000..24d4d407e --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.PopulateFrom.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Subscribes to the observable and calls AddOrUpdate on the source cache for each emitted batch of items. + /// + /// The type of the object. + /// The type of the key. + /// The to operate on. + /// The that emits batches of items. + /// An that, when disposed, unsubscribes from . + /// + /// Each emission from is passed to , producing one changeset per emission containing Add or Update events for each item. Errors from propagate and terminate the subscription. Completion ends the subscription; the cache retains all items. + /// + /// or is . + /// + /// + public static IDisposable PopulateFrom(this ISourceCache source, IObservable> observable) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return observable.Subscribe(source.AddOrUpdate); + } + + /// + /// Subscribes to the observable and calls AddOrUpdate on the source cache for each emitted item. + /// + /// The type of the object. + /// The type of the key. + /// The to operate on. + /// The that emits individual items. + /// An that, when disposed, unsubscribes from . + /// or is . + public static IDisposable PopulateFrom(this ISourceCache source, IObservable observable) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return observable.Subscribe(source.AddOrUpdate); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.PopulateInto.cs b/src/DynamicData/Cache/ObservableCacheEx.PopulateInto.cs new file mode 100644 index 000000000..d5ce99d73 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.PopulateInto.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Subscribes to the changeset stream and clones each changeset into the destination cache. + /// + /// The type of the object. + /// The type of the key. + /// The source to pipe into a target cache. + /// The that will receive the changes. + /// An that, when disposed, unsubscribes from the source. + /// + /// + /// Each changeset from the source is applied to the destination cache inside an Edit call. + /// + /// + /// EventBehavior + /// AddThe item is added to the destination cache via AddOrUpdate. + /// UpdateThe item is updated in the destination cache via AddOrUpdate. + /// RemoveThe item is removed from the destination cache. + /// RefreshA Refresh is issued on the destination cache for the item. + /// OnErrorThe subscription is terminated. The destination cache is not rolled back. + /// OnCompletedThe subscription ends. The destination cache retains all items. + /// + /// + /// or is . + /// + /// + /// + public static IDisposable PopulateInto(this IObservable> source, ISourceCache destination) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); + } + + /// + /// The source to pipe into a target cache. + /// The that will receive the changes. + /// Overload that targets an . + public static IDisposable PopulateInto(this IObservable> source, IIntermediateCache destination) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); + } + + /// + /// The source to pipe into a target cache. + /// The that will receive the changes. + /// Overload that targets a . + public static IDisposable PopulateInto(this IObservable> source, LockFreeObservableCache destination) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.QueryWhenChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.QueryWhenChanged.cs new file mode 100644 index 000000000..f294f119c --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.QueryWhenChanged.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Projects the current cache state through after each modification. + /// Emits a new value of on every changeset. + /// + /// The type of the object. + /// The type of the key. + /// The type of the destination. + /// The source to project on each change. + /// A function that projects the current snapshot to a result value. + /// An observable that emits a projected value after each changeset. + /// + /// Worth noting: The selector is called on every changeset, which can be chatty. The exposes the full cache state for LINQ-style queries. + /// + /// or is . + /// + /// + /// + public static IObservable QueryWhenChanged(this IObservable> source, Func, TDestination> resultSelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return source.QueryWhenChanged().Select(resultSelector); + } + + /// + /// The latest copy of the cache is exposed for querying i) after each modification to the underlying data ii) upon subscription. + /// + /// The type of the object. + /// The type of the key. + /// The source to project on each change. + /// An observable which emits the query. + /// source. + public static IObservable> QueryWhenChanged(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new QueryWhenChanged(source).Run(); + } + + /// + /// The latest copy of the cache is exposed for querying i) after each modification to the underlying data ii) on subscription. + /// + /// The type of the object. + /// The type of the key. + /// The type of the value. + /// The source to project on each change. + /// A that should the query be triggered for observables on individual items. + /// An observable that emits the query. + /// source. + public static IObservable> QueryWhenChanged(this IObservable> source, Func> itemChangedTrigger) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + itemChangedTrigger.ThrowArgumentNullExceptionIfNull(nameof(itemChangedTrigger)); + + return new QueryWhenChanged(source, itemChangedTrigger).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.RefCount.cs b/src/DynamicData/Cache/ObservableCacheEx.RefCount.cs new file mode 100644 index 000000000..66c8315b5 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.RefCount.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Cache-aware equivalent of Publish().RefCount(). An internal cache is created on the first subscriber + /// and disposed when the last subscriber unsubscribes. All subscribers share the same upstream subscription. + /// + /// The type of the object. + /// The type of the key. + /// The source to share via reference counting. + /// A ref-counted observable changeset stream. + /// + public static IObservable> RefCount(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new RefCount(source).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Refresh.cs b/src/DynamicData/Cache/ObservableCacheEx.Refresh.cs new file mode 100644 index 000000000..4e9947f28 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Refresh.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Signals downstream operators to re-evaluate the specified item. Produces a changeset with a single Refresh change. + /// + /// The type of the object. + /// The type of the key. + /// The to signal re-evaluation on. + /// The item to refresh. + /// + /// Convenience method that wraps a Refresh inside . A Refresh does not change data in the cache; it signals downstream operators (such as or ) to re-evaluate the item. + /// + /// is . + /// + /// + public static void Refresh(this ISourceCache source, TObject item) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Refresh(item)); + } + + /// + /// Signals downstream operators to re-evaluate the specified items. Produces one changeset with a Refresh for each item. + /// + /// The type of the object. + /// The type of the key. + /// The to signal re-evaluation on. + /// The of items to refresh. + /// is . + public static void Refresh(this ISourceCache source, IEnumerable items) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Refresh(items)); + } + + /// + /// Signals downstream operators to re-evaluate all items in the cache. Produces one changeset with a Refresh for every item. + /// + /// The type of the object. + /// The type of the key. + /// The to signal re-evaluation on. + /// is . + public static void Refresh(this ISourceCache source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Refresh()); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Remove.cs b/src/DynamicData/Cache/ObservableCacheEx.Remove.cs new file mode 100644 index 000000000..d835ff00b --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Remove.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Removes the specified item from the cache. Produces a Remove changeset if the item exists, nothing otherwise. + /// + /// The type of the object. + /// The type of the key. + /// The from which to remove items. + /// The item to remove. + /// + /// Convenience method that wraps a single-item removal inside . The key is extracted from the item using the cache's key selector. + /// + /// is . + /// + /// + /// + public static void Remove(this ISourceCache source, TObject item) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Remove(item)); + } + + /// + /// Removes the item with the specified key from the cache. Produces a Remove changeset if the key exists, nothing otherwise. + /// + /// The type of the object. + /// The type of the key. + /// The from which to remove items. + /// The key of the item to remove. + /// is . + public static void Remove(this ISourceCache source, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Remove(key)); + } + + /// + /// Removes the specified items from the cache. Any items not present in the cache are ignored. + /// Produces a Remove changeset for each item that existed. + /// + /// The type of the object. + /// The type of the key. + /// The from which to remove items. + /// The of items to remove. + /// is . + public static void Remove(this ISourceCache source, IEnumerable items) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Remove(items)); + } + + /// + /// Removes the items with the specified keys from the cache. Any keys not present are ignored. + /// Produces a Remove changeset for each key that existed. + /// + /// The type of the object. + /// The type of the key. + /// The from which to remove items. + /// The keys to remove. + /// is . + public static void Remove(this ISourceCache source, IEnumerable keys) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Remove(keys)); + } + + /// + /// The from which to remove items. + /// The key of the item to remove. + /// Overload that targets an . + public static void Remove(this IIntermediateCache source, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Remove(key)); + } + + /// + /// The from which to remove items. + /// The keys to remove. + /// Overload that targets an . + public static void Remove(this IIntermediateCache source, IEnumerable keys) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.Remove(keys)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.RemoveKey.cs b/src/DynamicData/Cache/ObservableCacheEx.RemoveKey.cs new file mode 100644 index 000000000..6e9f3ecd6 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.RemoveKey.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Strips the key from a cache changeset, converting to + /// (list changeset). All indexed changes are dropped (sorting is not supported). + /// + /// The type of the object. + /// The type of the key. + /// The source to strip keys from, producing an unkeyed list changeset. + /// A list changeset stream without key information. + /// + /// + public static IObservable> RemoveKey(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select( + changes => + { + var enumerator = new RemoveKeyEnumerator(changes); + return new ChangeSet(enumerator); + }); + } + + /// + /// Removes a specific key from the cache. Equivalent to source.Edit(u => u.RemoveKey(key)). + /// + /// The type of the object. + /// The type of the key. + /// The from which to remove a key. + /// The key to remove. + /// is . + public static void RemoveKey(this ISourceCache source, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.RemoveKey(key)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.RemoveKeys.cs b/src/DynamicData/Cache/ObservableCacheEx.RemoveKeys.cs new file mode 100644 index 000000000..837bf45f3 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.RemoveKeys.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Removes multiple keys from the cache in a single Edit call. Keys not present in the cache are ignored. + /// + /// The type of the object. + /// The type of the key. + /// The from which to remove keys. + /// The keys to remove. + /// is . + public static void RemoveKeys(this ISourceCache source, IEnumerable keys) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + source.Edit(updater => updater.RemoveKeys(keys)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.RightJoin.cs b/src/DynamicData/Cache/ObservableCacheEx.RightJoin.cs new file mode 100644 index 000000000..7bd66b375 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.RightJoin.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the optional left and right values into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> RightJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TRight, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.RightJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Joins two changeset streams, producing a result for every right-side key. The left side is + /// because a matching left item may or may not exist. All right items + /// appear in the output regardless. Equivalent to SQL RIGHT OUTER JOIN. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the right key, optional left, and right value into a destination object. Example: (rightKey, left, right) => new Result(rightKey, left, right). + /// An observable changeset keyed by . + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddAlways emits. Invokes with the matching left (or ) and the right value. + /// UpdateRe-invokes the selector with current left (if any) and the new right value. + /// RemoveRemoves the joined result. + /// RefreshForwarded as Refresh on the joined result. + /// + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddIf matching right items exist, re-invokes the selector (left transitions from None to Some) and emits Updates. + /// UpdateIf matching right items exist, re-invokes the selector with the new left value. + /// RemoveIf matching right items exist, re-invokes the selector (left transitions from Some to None) and emits Updates. + /// RefreshIf joined results exist, forwarded as Refresh. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> RightJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TRight, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new RightJoin(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.RightJoinMany.cs b/src/DynamicData/Cache/ObservableCacheEx.RightJoinMany.cs new file mode 100644 index 000000000..cb6a5a924 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.RightJoinMany.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the optional left value and the right group into a destination object. The key is not provided in this overload. + /// Overload that omits the key from the result selector. Delegates to . + public static IObservable> RightJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return left.RightJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); + } + + /// + /// Groups right-side items by their mapped key, then right-joins each group to the left source. + /// A result is produced for every key that has at least one right item. The left value is + /// because a matching left item may or may not exist. + /// Equivalent to SQL RIGHT OUTER JOIN with the right side grouped. + /// + /// The item type of the left source. + /// The key type of the left source. + /// The item type of the right source. + /// The key type of the right source. + /// The type produced by . + /// The left to join. + /// The right to join. + /// A that maps each right item to the left key it should join on. + /// A that combines the key, optional left value, and right group into a destination object. Example: (key, left, group) => new Result(key, left, group). + /// An observable changeset keyed by . + /// + /// + /// Right-side change handling: + /// + /// EventBehavior + /// AddUpdates the right group. If the group was previously empty, emits an Add with the current left (if any). Otherwise emits an Update. + /// UpdateUpdates the right group and re-invokes . + /// RemoveUpdates the right group. If the group becomes empty, removes the joined result. + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// + /// Left-side change handling: + /// + /// EventBehavior + /// AddIf a non-empty right group exists, re-invokes the selector (left transitions from None to Some) and emits an Update. + /// UpdateIf a non-empty right group exists, re-invokes the selector with the new left value. + /// RemoveIf a non-empty right group exists, re-invokes the selector (left transitions from Some to None) and emits an Update. + /// RefreshIf a joined result exists, forwarded as Refresh. + /// + /// + /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. + /// + /// Any argument is . + /// + /// + /// + /// + public static IObservable> RightJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) + where TLeft : notnull + where TLeftKey : notnull + where TRight : notnull + where TRightKey : notnull + where TDestination : notnull + { + left.ThrowArgumentNullExceptionIfNull(nameof(left)); + right.ThrowArgumentNullExceptionIfNull(nameof(right)); + rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return new RightJoinMany(left, right, rightKeySelector, resultSelector).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.SkipInitial.cs b/src/DynamicData/Cache/ObservableCacheEx.SkipInitial.cs new file mode 100644 index 000000000..6a2ed2db4 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.SkipInitial.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Skips the initial snapshot changeset that Connect() typically emits, then forwards all subsequent changesets. + /// Internally uses DeferUntilLoaded().Skip(1). + /// + /// The type of the object. + /// The type of the key. + /// The source to skip the initial changeset. + /// An observable that skips the first changeset and forwards all others. + /// is . + /// + /// + public static IObservable> SkipInitial(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.DeferUntilLoaded().Skip(1); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Sort.cs b/src/DynamicData/Cache/ObservableCacheEx.Sort.cs new file mode 100644 index 000000000..87bccebe5 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Sort.cs @@ -0,0 +1,119 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Obsolete: use SortAndBind instead. Sorts using the specified comparer. + /// + /// The type of the object. + /// The type of the key. + /// The source to sort. + /// The used to determine sort order. + /// A that sort optimisation flags. Specify one or more sort optimisations. + /// The number of updates before the entire list is resorted (rather than inline sort). + /// An observable which emits change sets. + /// + /// source + /// or + /// comparer. + /// + /// + [Obsolete(Constants.SortIsObsolete)] + public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new Sort(source, comparer, sortOptimisations, resetThreshold: resetThreshold).Run(); + } + + /// + /// Obsolete: use SortAndBind instead. Sorts using a dynamic comparer observable. + /// + /// The type of the object. + /// The type of the key. + /// The source to sort. + /// The comparer observable. + /// The sort optimisations. + /// The reset threshold. + /// An observable which emits change sets. + [Obsolete(Constants.SortIsObsolete)] + public static IObservable> Sort(this IObservable> source, IObservable> comparerObservable, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparerObservable.ThrowArgumentNullExceptionIfNull(nameof(comparerObservable)); + + return new Sort(source, null, sortOptimisations, comparerObservable, resetThreshold: resetThreshold).Run(); + } + + /// + /// Obsolete: use SortAndBind instead. Sorts using a dynamic comparer observable with a manual re-sort signal. + /// + /// The type of the object. + /// The type of the key. + /// The source to sort. + /// The comparer observable. + /// An that signals the algorithm to re-sort the entire data set. + /// The sort optimisations. + /// The reset threshold. + /// An observable which emits change sets. + [Obsolete(Constants.SortIsObsolete)] + public static IObservable> Sort(this IObservable> source, IObservable> comparerObservable, IObservable resorter, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparerObservable.ThrowArgumentNullExceptionIfNull(nameof(comparerObservable)); + + return new Sort(source, null, sortOptimisations, comparerObservable, resorter, resetThreshold).Run(); + } + + /// + /// Obsolete: use SortAndBind instead. Sorts using a static comparer with a manual re-sort signal. + /// + /// The type of the object. + /// The type of the key. + /// The source to sort. + /// The used to determine sort order. + /// An that signals the algorithm to re-sort the entire data set. + /// The sort optimisations. + /// The reset threshold. + /// An observable which emits change sets. + [Obsolete(Constants.SortIsObsolete)] + public static IObservable> Sort(this IObservable> source, IComparer comparer, IObservable resorter, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + resorter.ThrowArgumentNullExceptionIfNull(nameof(resorter)); + + return new Sort(source, comparer, sortOptimisations, null, resorter, resetThreshold).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs index 9ce7130f2..9f5e1444c 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs @@ -9,7 +9,7 @@ namespace DynamicData; /// -/// ObservableCache extensions for SortAndBind. +/// Extensions for dynamic data. /// public static partial class ObservableCacheEx { diff --git a/src/DynamicData/Cache/ObservableCacheEx.SortBy.cs b/src/DynamicData/Cache/ObservableCacheEx.SortBy.cs new file mode 100644 index 000000000..2834f6084 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.SortBy.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Sorts the changeset stream by the value returned from . Creates a comparer internally + /// and delegates to . + /// Since Sort is obsolete, prefer SortAndBind for new code. + /// + /// The type of the object. + /// The type of the key. + /// The source to sort. + /// A that expression that selects a comparable value from each item. + /// The sort direction. Defaults to ascending. + /// A that sort optimization flags. + /// The number of updates before the entire list is re-sorted (rather than inline sort). + /// An observable that emits sorted changesets. + public static IObservable> SortBy( + this IObservable> source, + Func expression, + SortDirection sortOrder = SortDirection.Ascending, + SortOptimisations sortOptimisations = SortOptimisations.None, + int resetThreshold = DefaultSortResetThreshold) + where TObject : notnull + where TKey : notnull + { + source = source ?? throw new ArgumentNullException(nameof(source)); + expression = expression ?? throw new ArgumentNullException(nameof(expression)); + + return source.Sort( + sortOrder switch + { + SortDirection.Descending => SortExpressionComparer.Descending(expression), + _ => SortExpressionComparer.Ascending(expression), + }, + sortOptimisations, + resetThreshold); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.StartWithEmpty.cs b/src/DynamicData/Cache/ObservableCacheEx.StartWithEmpty.cs new file mode 100644 index 000000000..8960f03aa --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.StartWithEmpty.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Prepends an empty changeset to the source stream, ensuring subscribers always receive an immediate + /// (empty) notification on subscription. Uses Rx's StartWith. + /// + /// The type of the object. + /// The type of the key. + /// The source to prepend an empty changeset to. + /// An observable that emits an empty changeset first, then all source changesets. + /// + public static IObservable> StartWithEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.StartWith(ChangeSet.Empty); + + /// + /// The source to prepend an empty changeset to. + /// An observable that emits an empty sorted changeset first, then all source changesets. + /// Overload for . + public static IObservable> StartWithEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.StartWith(SortedChangeSet.Empty); + + /// + /// The source to prepend an empty changeset to. + /// An observable that emits an empty virtual changeset first, then all source changesets. + /// Overload for . + public static IObservable> StartWithEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.StartWith(VirtualChangeSet.Empty); + + /// + /// The source to prepend an empty changeset to. + /// An observable that emits an empty paged changeset first, then all source changesets. + /// Overload for . + public static IObservable> StartWithEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.StartWith(PagedChangeSet.Empty); + + /// + /// The type of the object. + /// The type of the key. + /// The grouping key type. + /// The source to prepend an empty changeset to. + /// An observable that emits an empty group changeset first, then all source changesets. + /// Overload for . + public static IObservable> StartWithEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull => source.StartWith(GroupChangeSet.Empty); + + /// + /// The type of the object. + /// The type of the key. + /// The grouping key type. + /// The source to prepend an empty changeset to. + /// An observable that emits an empty immutable group changeset first, then all source changesets. + /// Overload for . + public static IObservable> StartWithEmpty(this IObservable> source) + where TObject : notnull + where TKey : notnull + where TGroupKey : notnull => source.StartWith(ImmutableGroupChangeSet.Empty); + + /// + /// The type of the item. + /// The source of to prepend an empty changeset to. + /// An observable that emits an empty collection first, then all source collections. + /// Overload for . + public static IObservable> StartWithEmpty(this IObservable> source) => source.StartWith(ReadOnlyCollectionLight.Empty); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.StartWithItem.cs b/src/DynamicData/Cache/ObservableCacheEx.StartWithItem.cs new file mode 100644 index 000000000..560f53723 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.StartWithItem.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// The source to prepend an initial item to. + /// The item to prepend. The key is extracted from . + /// Overload for items that implement . Delegates to the explicit key overload. + public static IObservable> StartWithItem(this IObservable> source, TObject item) + where TObject : IKey + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.StartWithItem(item, item.Key); + } + + /// + /// Prepends a changeset containing a single Add for the given item and key to the source stream. + /// The Rx equivalent of StartWith, but wrapped as a DynamicData changeset. + /// + /// The type of the object. + /// The type of the key. + /// The source to prepend an initial item to. + /// The item to prepend. + /// The key for the item. + /// An observable that emits a single-item Add changeset first, then all source changesets. + public static IObservable> StartWithItem(this IObservable> source, TObject item, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + var change = new Change(ChangeReason.Add, key, item); + return source.StartWith(new ChangeSet { change }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.SubscribeMany.cs b/src/DynamicData/Cache/ObservableCacheEx.SubscribeMany.cs new file mode 100644 index 000000000..4149907ba --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.SubscribeMany.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Creates an subscription per item via . + /// Subscriptions are created on Add/Update and disposed on Update/Remove. All active subscriptions + /// are disposed when the stream completes, errors, or the subscription is disposed. + /// + /// The type of the object. + /// The type of the key. + /// The source to create a subscription for each item in. + /// A factory that creates an for each item. Called on Add and Update (for the new value). + /// A stream that forwards all changesets from unchanged. + /// + /// + /// Change reason handling: + /// + /// EventBehavior + /// AddCalls , stores the returned . + /// UpdateDisposes the previous subscription, then calls for the new value. + /// RemoveDisposes the subscription for the removed item. + /// RefreshPassed through. No subscription change. + /// + /// + /// + /// Internally implemented using + /// and , so disposal semantics match . + /// + /// + /// Use this to tie per-item side effects (event subscriptions, polling timers, child observable subscriptions) + /// to the lifecycle of items in the cache. + /// + /// + /// or is . + /// + /// + /// + public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); + + return new SubscribeMany(source, subscriptionFactory).Run(); + } + + /// + /// The source to create a subscription for each item in. + /// A factory that creates an for each item. Receives the item and its key. + /// Overload whose factory receives both the item and the key. See for full details. + public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); + + return new SubscribeMany(source, subscriptionFactory).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.SuppressRefresh.cs b/src/DynamicData/Cache/ObservableCacheEx.SuppressRefresh.cs new file mode 100644 index 000000000..452c4ea0d --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.SuppressRefresh.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Suppress refresh notifications. + /// + /// The object of the change set. + /// The key of the change set. + /// The source to strip refresh events. + /// An observable which emits change sets. + public static IObservable> SuppressRefresh(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.WhereReasonsAreNot(ChangeReason.Refresh); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Switch.cs b/src/DynamicData/Cache/ObservableCacheEx.Switch.cs new file mode 100644 index 000000000..c300179c1 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Switch.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// An observable that emits instances. + /// Overload that accepts observable caches. Internally calls Connect() on each cache and delegates to the changeset overload. + public static IObservable> Switch(this IObservable> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Select(cache => cache.Connect()).Switch(); + } + + /// + /// Subscribes to the latest inner changeset stream, unsubscribing from the previous one on each switch. + /// When switching, the old source's items are removed and the new source's items are added. + /// + /// The type of the object. + /// The type of the key. + /// An of changeset streams. The operator subscribes to the latest inner stream. + /// A changeset stream reflecting the items from the most recently emitted inner source. + /// + /// On switch: Remove is emitted for all items from the previous source, then Add for all items from the new source. + /// Worth noting: Each switch clears the entire downstream cache before populating from the new source. Subscribers see a full remove-then-add reset on every switch. + /// + public static IObservable> Switch(this IObservable>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new Switch(sources).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ToCollection.cs b/src/DynamicData/Cache/ObservableCacheEx.ToCollection.cs new file mode 100644 index 000000000..1871e4337 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ToCollection.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Converts the change set into a fully formed collection. Each change in the source results in a new collection. + /// + /// The type of the object. + /// The type of the key. + /// The source to materialize into a collection on each change. + /// An observable which emits the read only collection. + /// + public static IObservable> ToCollection(this IObservable> source) + where TObject : notnull + where TKey : notnull => source.QueryWhenChanged(query => new ReadOnlyCollectionLight(query.Items)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs b/src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs new file mode 100644 index 000000000..f04d2c998 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Bridges a standard Rx observable of individual items into a DynamicData changeset stream. + /// Each emission becomes an Add (or Update if the key already exists). + /// Supports optional per-item expiration and size limiting. + /// + /// The type of the object. + /// The type of the key. + /// The source to convert into a keyed changeset stream. + /// A that selects the unique key for each item. + /// An optional that specifies per-item expiration time. Return for no expiration. + /// The maximum cache size. Oldest items are removed when exceeded. Use -1 for no limit. + /// An optional for expiration timing. + /// An observable changeset stream. + /// or is . + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func keySelector, + Func? expireAfter = null, + int limitSizeTo = -1, + IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return Cache.Internal.ToObservableChangeSet.Create( + source: source, + keySelector: keySelector, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + } + + /// + /// Bridges a standard Rx observable of item batches into a DynamicData changeset stream. + /// Each batch is processed with AddOrUpdate, producing Add or Update changes per item. + /// Supports optional per-item expiration and size limiting. + /// + /// The type of the object. + /// The type of the key. + /// The source to convert into a keyed changeset stream. + /// A that selects the unique key for each item. + /// An optional that specifies per-item expiration time. Return for no expiration. + /// The maximum cache size. Oldest items are removed when exceeded. Use -1 for no limit. + /// An optional for expiration timing. + /// An observable changeset stream. + /// or is . + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func keySelector, + Func? expireAfter = null, + int limitSizeTo = -1, + IScheduler? scheduler = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return Cache.Internal.ToObservableChangeSet.Create( + source: source, + keySelector: keySelector, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ToObservableOptional.cs b/src/DynamicData/Cache/ObservableCacheEx.ToObservableOptional.cs new file mode 100644 index 000000000..43d2c39eb --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ToObservableOptional.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Watches a single key in the source changeset stream, emitting Optional.Some(value) when the key + /// is present and when it is removed. Duplicate values are suppressed via . + /// + /// The type of the object. + /// The type of the key. + /// The source to watch a single key in. + /// The key to watch. + /// An that optional comparer to suppress duplicate emissions. Uses default equality if . + /// An observable of that reflects the presence or absence of the specified key. + /// + /// + /// Unlike , this emits None on removal + /// (rather than the removed value), making it possible to distinguish "key is absent" from "key has a value". + /// + /// + /// EventBehavior + /// AddEmits Optional.Some(value) if the key was not previously tracked. + /// UpdateEmits Optional.Some(newValue) if the new value differs from the previous per . Otherwise suppressed. + /// RemoveEmits . + /// RefreshEmits Optional.Some(value) if the value differs from the last emission per . Otherwise suppressed. + /// + /// Worth noting: No emission occurs if the key is not present at subscription time. To get an initial None when the key is absent, use the overload with initialOptionalWhenMissing: true. + /// + /// is . + /// + /// + public static IObservable> ToObservableOptional(this IObservable> source, TKey key, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new ToObservableOptional(source, key, equalityComparer).Run(); + } + + /// + /// Converts an observable cache into an observable optional that emits the value for the given key. + /// + /// The type of the object. + /// The type of the key. + /// The source to watch a single key in. + /// The key value. + /// When , emits an initial with no value if the key is not present in the cache. + /// An optional instance used to determine if an object value has changed. + /// An observable optional. + /// source is null. + /// + /// Worth noting: Uses lock-based coordination. If the key exists synchronously on Connect(), the initial None may or may not be emitted depending on timing. + /// + public static IObservable> ToObservableOptional(this IObservable> source, TKey key, bool initialOptionalWhenMissing, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TKey : notnull + { + if (initialOptionalWhenMissing) + { + return Observable.Defer(() => + { + var seenValue = false; + return source.ToObservableOptional(key, equalityComparer) + .Do(_ => seenValue = true) + .Merge(Observable.Defer(() => seenValue + ? Observable.Empty>() + : Observable.Return(Optional.None()))); + }); + } + + return source.ToObservableOptional(key, equalityComparer); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ToSortedCollection.cs b/src/DynamicData/Cache/ObservableCacheEx.ToSortedCollection.cs new file mode 100644 index 000000000..81770d625 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ToSortedCollection.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Converts the change set into a fully formed sorted collection. Each change in the source results in a new sorted collection. + /// + /// The type of the object. + /// The type of the key. + /// The sort key. + /// The source to materialize into a sorted collection on each change. + /// The sort function. + /// The sort order. Defaults to ascending. + /// An observable which emits the read only collection. + /// + public static IObservable> ToSortedCollection(this IObservable> source, Func sort, SortDirection sortOrder = SortDirection.Ascending) + where TObject : notnull + where TKey : notnull + where TSortKey : notnull => source.QueryWhenChanged(query => sortOrder == SortDirection.Ascending ? new ReadOnlyCollectionLight(query.Items.OrderBy(sort)) : new ReadOnlyCollectionLight(query.Items.OrderByDescending(sort))); + + /// + /// Converts the change set into a fully formed sorted collection. Each change in the source results in a new sorted collection. + /// + /// The type of the object. + /// The type of the key. + /// The source to materialize into a sorted collection on each change. + /// The sort comparer. + /// An observable which emits the read only collection. + public static IObservable> ToSortedCollection(this IObservable> source, IComparer comparer) + where TObject : notnull + where TKey : notnull => source.QueryWhenChanged( + query => + { + var items = query.Items.AsList(); + items.Sort(comparer); + return new ReadOnlyCollectionLight(items); + }); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Transform.cs b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs new file mode 100644 index 000000000..8319591d0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs @@ -0,0 +1,181 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). The factory receives only the current item. + /// + public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((current, _, _) => transformFactory(current), transformOnRefresh); + } + + /// + /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). The factory receives the current item and key. + public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((current, _, key) => transformFactory(current, key), transformOnRefresh); + } + + /// + /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). + public static IObservable> Transform(this IObservable> source, Func, TKey, TDestination> transformFactory, bool transformOnRefresh) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new Transform(source, transformFactory, transformOnRefresh: transformOnRefresh).Run(); + } + + /// + /// This overload accepts an optional forceTransform predicate filtering by source item only (without the key). The factory receives only the current item. + public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((current, _, _) => transformFactory(current), forceTransform?.ForForced()); + } + + /// + /// This overload accepts an optional forceTransform predicate filtering by source item and key. The factory receives the current item and key. + public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((current, _, key) => transformFactory(current, key), forceTransform); + } + + /// + /// Projects each item in the changeset to a new form using a synchronous transform factory. + /// + /// The type of the transformed items. + /// The type of the source items. + /// The type of the key. + /// The source to transform. + /// The that produces a from the current source item, the previous source item (if any), and the key. + /// An observable that, when it emits a predicate, re-transforms all items for which the predicate returns . Re-transformed items are emitted as changes. If , no forced re-transforms occur. + /// An observable changeset of transformed items. + /// + /// + /// Transform maintains a 1:1 mapping between source and destination items, keyed identically. The factory + /// is called once per Add and once per Update. Removes are forwarded without calling the factory. + /// + /// Change reason handling: + /// + /// Input reasonOutput behavior + /// AddCalls factory, emits Add. + /// UpdateCalls factory (receives current item, previous item, key), emits Update with Previous preserved. + /// RemoveEmits Remove. Factory is NOT called. + /// RefreshForwarded as Refresh without re-transforming. To re-transform on Refresh, use the parameter or the transformOnRefresh overloads. + /// + /// Worth noting: By default, Refresh does NOT re-invoke the transform factory (it is just forwarded). Set transformOnRefresh: true to re-transform on Refresh. + /// + /// When emits a predicate, every cached item is tested against it. + /// Matching items are re-transformed and emitted as Updates. + /// + /// + /// Factory exceptions propagate as , terminating the stream. + /// Use + /// to catch factory errors without killing the stream. + /// + /// + /// + /// + /// + /// or is . + public static IObservable> Transform(this IObservable> source, Func, TKey, TDestination> transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + if (forceTransform is not null) + { + return new TransformWithForcedTransform(source, transformFactory, forceTransform).Run(); + } + + return new Transform(source, transformFactory).Run(); + } + + /// + /// This overload accepts of to force re-transformation of ALL items when the observable emits. The factory receives only the current item. + public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable forceTransform) + where TDestination : notnull + where TSource : notnull + where TKey : notnull => source.Transform((cur, _, _) => transformFactory(cur), forceTransform.ForForced()); + + /// + /// This overload accepts of to force re-transformation of ALL items when the observable emits. The factory receives the current item and key. + public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable forceTransform) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); + + return source.Transform((cur, _, key) => transformFactory(cur, key), forceTransform.ForForced()); + } + + /// + /// This overload accepts of to force re-transformation of ALL items when the observable emits. + public static IObservable> Transform(this IObservable> source, Func, TKey, TDestination> transformFactory, IObservable forceTransform) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); + + return source.Transform(transformFactory, forceTransform.ForForced()); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs new file mode 100644 index 000000000..dc13c5a2f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// This overload takes a simpler factory that receives only the current item. + /// + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((current, _, _) => transformFactory(current), forceTransform); + } + + /// + /// This overload takes a factory that receives the current item and key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((current, _, key) => transformFactory(current, key), forceTransform); + } + + /// + /// Async version of . + /// Projects each item using an async factory that returns . + /// + /// The type of the transformed items. + /// The type of the source items. + /// The type of the key. + /// The source to transform asynchronously. + /// The async function that produces a from the current source item, the previous source item (if any), and the key. + /// An observable that, when it emits a predicate, re-transforms all items for which the predicate returns . Re-transformed items are emitted as changes. If , no forced re-transforms occur. + /// An observable changeset of transformed items. + /// + /// + /// Transforms within a single changeset batch execute concurrently. The entire batch must complete + /// before the resulting changeset is emitted. Use the overloads + /// to control maximum concurrency and Refresh handling. + /// + /// Change reason handling: + /// + /// Input reasonOutput behavior + /// AddAwaits factory, emits Add. + /// UpdateAwaits factory (receives current, previous, key), emits Update. + /// RemoveEmits Remove. Factory is NOT called. + /// RefreshForwarded as Refresh by default. Use to re-transform. + /// + /// Worth noting: Transforms are batched per changeset (all tasks must complete before the next changeset is processed). Completion waits for in-flight transforms. Remove does NOT cancel in-flight transforms for the removed key. + /// + /// Factory exceptions propagate as . Use + /// + /// to catch factory errors without terminating the stream. + /// + /// + /// or is . + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new TransformAsync(source, transformFactory, null, forceTransform).Run(); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. The factory receives only the current item. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((current, _, _) => transformFactory(current), options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. The factory receives the current item and key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((current, _, key) => transformFactory(current, key), options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new TransformAsync(source, transformFactory, null, null, options.MaximumConcurrency, options.TransformOnRefresh).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformImmutable.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformImmutable.cs new file mode 100644 index 000000000..940746e7a --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformImmutable.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Optimized transform for immutable items with deterministic (pure) transform functions. + /// Refresh changes are dropped entirely since immutable items cannot change in place. + /// + /// The type of the transformed items. + /// The type of the source items. + /// The type of the key. + /// The source to transform (items assumed immutable). + /// The pure function that maps a source item to a destination item. Must be deterministic: same input always produces equivalent output. + /// An observable changeset of transformed items. + /// + /// + /// Because the transform is assumed to be stateless and deterministic, this operator does not track + /// previously transformed items. This reduces memory overhead compared to . + /// + /// Change reason handling: + /// + /// Input reasonOutput behavior + /// AddCalls factory, emits Add. + /// UpdateCalls factory, emits Update. + /// RemoveEmits Remove. Factory is NOT called. + /// RefreshDROPPED. Immutable items do not change, so Refresh is meaningless. + /// + /// Use this when items are immutable, the factory is pure, and the factory is cheap. If any of these conditions are false, use instead. + /// + /// or is . + public static IObservable> TransformImmutable( + this IObservable> source, + Func transformFactory) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new TransformImmutable( + source: source, + transformFactory: transformFactory) + .Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs new file mode 100644 index 000000000..682f5592d --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Flattens each source item into zero or more destination items (1:N), producing a single flat changeset. + /// Each child item must have a globally unique key across all parents. + /// + /// The type of the child items. + /// The type of the child item keys. + /// The type of the source (parent) items. + /// The type of the source (parent) keys. + /// The source to expand each item into multiple children. + /// A function that expands a parent item into its children. For or overloads, subsequent changes to the child collection are automatically tracked. + /// A that extracts a unique key from each child item. Keys must be unique across ALL parents, not just within one parent. + /// An observable changeset of flattened child items. + /// + /// Change reason handling: + /// + /// Input reasonOutput behavior + /// AddCalls , emits Add for each child. + /// UpdateDiffs old children vs new children: emits Remove for removed children, Add for new children, Update for children with matching keys. + /// RemoveEmits Remove for all children of the removed parent. + /// RefreshPropagated as Refresh to all children (no re-expansion). + /// + /// Worth noting: If two source items produce children with the same key, last-in-wins. Refresh does NOT re-expand children (only Update does). + /// If two parents produce children with the same key, last-in-wins. Use the async variant with a to control conflict resolution. + /// + /// , , or is . + /// + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); + + /// + /// This overload accepts an selector. Changes to the child collection (adds, removes, replacements) are automatically observed and reflected downstream. + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); + + /// + /// This overload accepts a selector. Changes to the child collection are automatically observed and reflected downstream. + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); + + /// + /// This overload accepts an selector. The child cache is live: subsequent changes to it are automatically propagated downstream. + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformManyAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformManyAsync.cs new file mode 100644 index 000000000..4a6ac6005 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformManyAsync.cs @@ -0,0 +1,128 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Async version of . + /// Flattens each source item into zero or more destination items using an async factory. + /// + /// The type of the child items. + /// The type of the child item keys. + /// The type of the source (parent) items. + /// The type of the source (parent) keys. + /// The source to expand each item into multiple children asynchronously. + /// An async function that expands a parent item (and its key) into an of children. + /// A that extracts a unique key from each child item. + /// An that optional comparer to determine if two child items with the same key are equal. Used to suppress no-op updates. + /// An that optional comparer to resolve key collisions when the same destination key is produced by multiple parents. The winning item is determined by this comparer. + /// An observable changeset of flattened child items. + /// + /// + /// Because each parent's expansion is async, child collections may arrive via separate changesets + /// (unlike the synchronous TransformMany which batches all children into one changeset). + /// + /// + /// Factory exceptions propagate as . Use + /// + /// to catch errors without killing the stream. + /// + /// + /// or is . + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + + return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer).Run(); + } + + /// + /// This overload takes a factory that receives only the source item (without the key). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => source.TransformManyAsync((val, _) => manySelector(val), keySelector, equalityComparer, comparer); + + /// + /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives the source item and its key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManyAsync(this IObservable> source, Func> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + where TCollection : INotifyCollectionChanged, IEnumerable + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + + return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer).Run(); + } + + /// + /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives only the source item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManyAsync(this IObservable> source, Func> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + where TCollection : INotifyCollectionChanged, IEnumerable => source.TransformManyAsync((val, _) => manySelector(val), keySelector, equalityComparer, comparer); + + /// + /// This overload returns an per parent. The child cache is live: its changes propagate downstream. No keySelector is needed since the cache already has keys. The factory receives the source item and its key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + + return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector), equalityComparer, comparer).Run(); + } + + /// + /// This overload returns an per parent. The child cache is live. The factory receives only the source item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => source.TransformManyAsync((val, _) => manySelector(val), equalityComparer, comparer); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformManySafeAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformManySafeAsync.cs new file mode 100644 index 000000000..5e9d2c062 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformManySafeAsync.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Async version of + /// with error handling. Factory exceptions are caught and routed to instead of + /// terminating the stream. + /// + /// The type of the child items. + /// The type of the child item keys. + /// The type of the source (parent) items. + /// The type of the source (parent) keys. + /// The source to expand each item into multiple children asynchronously with error handling. + /// An async function that expands a parent item (and its key) into an of children. + /// A that extracts a unique key from each child item. + /// A that called when throws. The faulting item is skipped and the stream continues. + /// An that optional comparer to determine if two child items with the same key are equal. + /// An that optional comparer to resolve key collisions when the same destination key is produced by multiple parents. + /// An observable changeset of flattened child items. + /// Because the transformations are asynchronous, each sub-collection may be emitted via a separate changeset. + /// , , or is . + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer, errorHandler).Run(); + } + + /// + /// This overload takes a factory that receives only the source item (without the key). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => source.TransformManySafeAsync((val, _) => manySelector(val), keySelector, errorHandler, equalityComparer, comparer); + + /// + /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives the source item and its key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManySafeAsync(this IObservable> source, Func> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + where TCollection : INotifyCollectionChanged, IEnumerable + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer, errorHandler).Run(); + } + + /// + /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives only the source item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManySafeAsync(this IObservable> source, Func> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + where TCollection : INotifyCollectionChanged, IEnumerable => source.TransformManySafeAsync((val, _) => manySelector(val), keySelector, errorHandler, equalityComparer, comparer); + + /// + /// This overload returns an per parent. The child cache is live. The factory receives the source item and its key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector), equalityComparer, comparer, errorHandler).Run(); + } + + /// + /// This overload returns an per parent. The child cache is live. The factory receives only the source item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TDestination : notnull + where TDestinationKey : notnull + where TSource : notnull + where TSourceKey : notnull => source.TransformManySafeAsync((val, _) => manySelector(val), errorHandler, equalityComparer, comparer); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs new file mode 100644 index 000000000..24d9e5e04 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Projects each item into a per-item observable. The latest value emitted by each item's observable + /// becomes the transformed value in the output changeset. + /// + /// The type of the source items. + /// The type of the key. + /// The type of the transformed items. + /// The source to transform using per-item observables. + /// A function that, given a source item and its key, returns an whose emissions become the transformed values. + /// An observable changeset where each key's value is the latest emission from its per-item observable. + /// + /// + /// Source changeset handling (parent events): + /// + /// + /// EventBehavior + /// AddCalls and subscribes to the returned observable. The item is not visible downstream until the observable emits its first value. + /// UpdateDisposes the old item's observable subscription and subscribes to the new item's observable. The item disappears from downstream until the new observable emits. + /// RemoveDisposes the item's observable subscription. If the item was visible downstream, a Remove is emitted. + /// RefreshForwarded as Refresh if the item is currently visible downstream. Otherwise dropped. + /// + /// + /// Per-item observable handling (transform observable events): + /// + /// + /// EmissionBehavior + /// First valueThe transformed item appears downstream as an Add. + /// Subsequent valuesEach new value replaces the previous one: an Update is emitted downstream. + /// ErrorTerminates the entire output stream. + /// CompletedThe item remains at its last emitted value. No further updates are possible for this item. + /// + /// + /// Worth noting: Items are invisible downstream until their per-item observable emits at least one value. + /// If an item's observable never emits, that item never appears in the output. The transform factory's selector + /// runs under an internal lock, so it must not synchronously access other DynamicData caches (deadlock risk in + /// cross-cache pipelines). The output completes when the source completes and all per-item observables have + /// also completed. + /// + /// + /// or is . + /// + /// + /// + public static IObservable> TransformOnObservable(this IObservable> source, Func> transformFactory) + where TSource : notnull + where TKey : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new TransformOnObservable(source, transformFactory).Run(); + } + + /// + /// This overload takes a factory that receives only the source item (without the key). + public static IObservable> TransformOnObservable(this IObservable> source, Func> transformFactory) + where TSource : notnull + where TKey : notnull + where TDestination : notnull + { + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformOnObservable((obj, _) => transformFactory(obj)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs new file mode 100644 index 000000000..f8e68385d --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// This overload accepts a simpler factory that receives only the current item, and a forceTransform predicate filtering by source item only. + public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafe((current, _, _) => transformFactory(current), errorHandler, forceTransform.ForForced()); + } + + /// + /// This overload accepts a factory that receives the current item and key. + public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafe((current, _, key) => transformFactory(current, key), errorHandler, forceTransform); + } + + /// + /// Projects each item using a synchronous factory, catching factory exceptions via a mandatory error handler + /// instead of terminating the stream. + /// + /// The type of the transformed items. + /// The type of the source items. + /// The type of the key. + /// The source to transform with error handling. + /// The that produces a from the current source item, the previous source item (if any), and the key. + /// A callback invoked when throws. Receives an containing the exception and the faulting item. The item is skipped and the stream continues. + /// An optional that, when it emits a predicate, re-transforms all items for which the predicate returns . If , no forced re-transforms occur. + /// An observable changeset of transformed items. + /// + /// + /// Behaves identically to + /// except that factory exceptions are routed to instead of propagating as . + /// Source-level errors (i.e. the source observable itself erroring) still propagate normally. + /// + /// Worth noting: Factory exceptions are caught per-item; the faulting item is skipped and reported to the error handler while the stream continues. Source-level errors still terminate the stream. + /// + /// , , or is . + public static IObservable> TransformSafe(this IObservable> source, Func, TKey, TDestination> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + if (forceTransform is not null) + { + return new TransformWithForcedTransform(source, transformFactory, forceTransform, errorHandler).Run(); + } + + return new Transform(source, transformFactory, errorHandler).Run(); + } + + /// + /// This overload accepts of to force re-transformation of ALL items. The factory receives only the current item. + public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable forceTransform) + where TDestination : notnull + where TSource : notnull + where TKey : notnull => source.TransformSafe((cur, _, _) => transformFactory(cur), errorHandler, forceTransform.ForForced()); + + /// + /// This overload accepts of to force re-transformation of ALL items. The factory receives the current item and key. + public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable forceTransform) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); + + return source.TransformSafe((cur, _, key) => transformFactory(cur, key), errorHandler, forceTransform.ForForced()); + } + + /// + /// This overload accepts of to force re-transformation of ALL items. + public static IObservable> TransformSafe(this IObservable> source, Func, TKey, TDestination> transformFactory, Action> errorHandler, IObservable forceTransform) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); + + return source.TransformSafe(transformFactory, errorHandler, forceTransform.ForForced()); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs new file mode 100644 index 000000000..45c842917 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// This overload takes a factory that receives only the current item. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafeAsync((current, _, _) => transformFactory(current), errorHandler, forceTransform); + } + + /// + /// This overload takes a factory that receives the current item and key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafeAsync((current, _, key) => transformFactory(current, key), errorHandler, forceTransform); + } + + /// + /// Async version of . + /// Projects each item using an async factory, catching factory exceptions via a mandatory error handler. + /// + /// The type of the transformed items. + /// The type of the source items. + /// The type of the key. + /// The source to transform asynchronously with error handling. + /// The async function that produces a . + /// A that called when throws or faults. The item is skipped and the stream continues. + /// An optional that forces re-transformation of matching items. + /// An observable changeset of transformed items. + /// Combines the async execution model of with the error-safe behavior of . + /// , , or is . + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return new TransformAsync(source, transformFactory, errorHandler, forceTransform).Run(); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. The factory receives only the current item. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafeAsync((current, _, _) => transformFactory(current), errorHandler, options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. The factory receives the current item and key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafeAsync((current, _, key) => transformFactory(current, key), errorHandler, options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return new TransformAsync(source, transformFactory, errorHandler, null, options.MaximumConcurrency, options.TransformOnRefresh).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformToTree.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformToTree.cs new file mode 100644 index 000000000..e0020280d --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformToTree.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Builds a hierarchical tree from a flat changeset using a parent key selector. + /// Each item becomes a with Parent, Children, Depth, and IsRoot properties. + /// + /// The type of the source items. Must be a reference type. + /// The type of the key. + /// The source to transform into a hierarchical tree. + /// The that returns the key of an item's parent. Return the item's own key (or a non-existent key) for root items. + /// An optional that emits a filter predicate for nodes. When the predicate changes, nodes are re-evaluated and filtered. + /// An observable changeset of items representing the tree. + /// + /// Change reason handling: + /// + /// Input reasonOutput behavior + /// AddCreates node, attaches to parent (or root if parent not found), emits Add. + /// UpdateUpdates node. If returns a different parent key, the node is re-parented. + /// RemoveRemoves node. Orphaned children become root nodes. + /// RefreshRe-evaluates parent key. May re-parent the node if the parent changed. + /// + /// Circular references are NOT detected. If item A is the parent of B and B is the parent of A, behavior is undefined. + /// + /// or is . + public static IObservable, TKey>> TransformToTree(this IObservable> source, Func pivotOn, IObservable, bool>>? predicateChanged = null) + where TObject : class + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pivotOn.ThrowArgumentNullExceptionIfNull(nameof(pivotOn)); + + return new TreeBuilder(source, pivotOn, predicateChanged).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformWithInlineUpdate.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformWithInlineUpdate.cs new file mode 100644 index 000000000..99004fa73 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformWithInlineUpdate.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// This overload defaults to transformOnRefresh: false and does not provide an error handler (factory exceptions propagate as OnError). + public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction) + where TDestination : class + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); + + return source.TransformWithInlineUpdate(transformFactory, updateAction, false); + } + + /// + /// This overload does not provide an error handler (factory exceptions propagate as OnError). The transformOnRefresh parameter controls Refresh behavior. + public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction, bool transformOnRefresh) + where TDestination : class + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); + + return new TransformWithInlineUpdate(source, transformFactory, updateAction, transformOnRefresh: transformOnRefresh).Run(); + } + + /// + /// This overload defaults to transformOnRefresh: false but includes an error handler for factory/update action exceptions. + public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction, Action> errorHandler) + where TDestination : class + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformWithInlineUpdate(transformFactory, updateAction, errorHandler, false); + } + + /// + /// Projects each item using a transform factory for Add, and mutates the existing transformed + /// item in place (via an update action) for Update, preserving the original object reference. + /// + /// The type of the transformed items. Must be a reference type since items are mutated in place. + /// The type of the source items. + /// The type of the key. + /// The source to transform with in-place mutation on updates. + /// A that called on Add (and optionally Refresh) to create a new . + /// A that called on Update. Receives (existingTransformed, newSource). Mutate the existing transformed item to reflect the new source value. Example: (vm, model) => vm.Value = model.Value. + /// A that called when or throws. The faulting item is skipped. + /// When , Refresh changes call on the existing item. + /// An observable changeset of transformed items. + /// + /// + /// This is useful when the destination type is a ViewModel that should maintain its identity across updates. + /// Instead of replacing the entire ViewModel, the update action patches the existing instance. + /// + /// Change reason handling: + /// + /// Input reasonOutput behavior + /// AddCalls , emits Add. + /// UpdateCalls on the EXISTING transformed item (same reference), emits Update. + /// RemoveEmits Remove. + /// RefreshIf is true, calls . Otherwise forwarded as Refresh. + /// + /// + /// , , , or is . + public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction, Action> errorHandler, bool transformOnRefresh) + where TDestination : class + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return new TransformWithInlineUpdate(source, transformFactory, updateAction, errorHandler, transformOnRefresh).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TreatMovesAsRemoveAdd.cs b/src/DynamicData/Cache/ObservableCacheEx.TreatMovesAsRemoveAdd.cs new file mode 100644 index 000000000..965001488 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TreatMovesAsRemoveAdd.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Converts moves changes to remove + add. + /// + /// The type of the object. + /// The type of the key. + /// The source to convert move events into remove/add pairs. + /// the same SortedChangeSets, except all moves are replaced with remove + add. + public static IObservable> TreatMovesAsRemoveAdd(this IObservable> source) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + static IEnumerable> ReplaceMoves(IChangeSet items) + { + foreach (var change in items.ToConcreteType()) + { + if (change.Reason == ChangeReason.Moved) + { + yield return new Change(ChangeReason.Remove, change.Key, change.Current, change.PreviousIndex); + + yield return new Change(ChangeReason.Add, change.Key, change.Current, change.CurrentIndex); + } + else + { + yield return change; + } + } + } + + return source.Select(changes => new SortedChangeSet(changes.SortedItems, ReplaceMoves(changes))); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TrueFor.cs b/src/DynamicData/Cache/ObservableCacheEx.TrueFor.cs new file mode 100644 index 000000000..a49b33f24 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TrueFor.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + private static IObservable TrueFor(this IObservable> source, Func> observableSelector, Func>, bool> collectionMatcher) + where TObject : notnull + where TKey : notnull + where TValue : notnull => new TrueFor(source, observableSelector, collectionMatcher).Run(); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TrueForAll.cs b/src/DynamicData/Cache/ObservableCacheEx.TrueForAll.cs new file mode 100644 index 000000000..e09acbedb --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TrueForAll.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Emits when all items in the cache satisfy a condition based on their per-item observable, + /// and otherwise. Re-evaluates whenever the cache changes or any per-item observable emits. + /// + /// The type of the object. + /// The type of the key. + /// The type of the value emitted by each per-item observable. + /// The source to evaluate a condition across all items in. + /// A factory that produces a condition observable for each item. + /// A that predicate applied to each per-item observable's latest value. + /// An observable of bool that emits whenever the all-items condition changes. + /// , , or is . + /// + /// + /// EventBehavior + /// AddA new per-item subscription is created. The aggregate condition is recalculated. + /// UpdateThe item is replaced in the collection snapshot. Condition recalculated. + /// RemovePer-item subscription disposed. Condition recalculated over remaining items. + /// RefreshNo effect on per-item subscriptions. Condition not recalculated unless the per-item observable emits. + /// + /// Worth noting: Items whose per-item observable has not yet emitted are treated as not satisfying the condition. An empty cache is vacuously . The result uses DistinctUntilChanged, so duplicate bool values are suppressed. + /// + /// + public static IObservable TrueForAll(this IObservable> source, Func> observableSelector, Func equalityCondition) + where TObject : notnull + where TKey : notnull + where TValue : notnull => source.TrueFor(observableSelector, items => items.All(o => o.LatestValue.HasValue && equalityCondition(o.LatestValue.Value))); + + /// + /// + /// Produces a boolean observable indicating whether the latest resulting value from all of the specified observables matches + /// the equality condition. The observable is re-evaluated whenever. + /// + /// + /// i) The cache changes + /// or ii) The inner observable changes. + /// + /// + /// The type of the object. + /// The type of the key. + /// The type of the value. + /// The source to evaluate a condition across all items in. + /// A that selector which returns the target observable. + /// The equality condition. + /// An observable which boolean values indicating if true. + /// source. + public static IObservable TrueForAll(this IObservable> source, Func> observableSelector, Func equalityCondition) + where TObject : notnull + where TKey : notnull + where TValue : notnull => source.TrueFor(observableSelector, items => items.All(o => o.LatestValue.HasValue && equalityCondition(o.Item, o.LatestValue.Value))); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TrueForAny.cs b/src/DynamicData/Cache/ObservableCacheEx.TrueForAny.cs new file mode 100644 index 000000000..f98e5270a --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TrueForAny.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Emits when any item in the cache satisfies a condition based on its per-item observable, + /// and when none do. Re-evaluates whenever the cache changes or any per-item observable emits. + /// + /// The type of the object. + /// The type of the key. + /// The type of the value emitted by each per-item observable. + /// The source to evaluate a condition across any item in. + /// A factory that produces a condition observable for each item. + /// A that predicate applied to each item and its per-item observable's latest value. + /// An observable of bool that emits whenever the any-item condition changes. + /// , , or is . + /// + /// + /// EventBehavior + /// AddA new per-item subscription is created. The aggregate condition is recalculated. + /// UpdateThe item is replaced in the collection snapshot. Condition recalculated. + /// RemovePer-item subscription disposed. Condition recalculated over remaining items. + /// RefreshNo effect on per-item subscriptions. Condition not recalculated unless the per-item observable emits. + /// + /// Worth noting: Items whose per-item observable has not yet emitted are treated as not satisfying the condition. An empty cache yields . The result uses DistinctUntilChanged, so duplicate bool values are suppressed. + /// + /// + public static IObservable TrueForAny(this IObservable> source, Func> observableSelector, Func equalityCondition) + where TObject : notnull + where TKey : notnull + where TValue : notnull => source.TrueFor(observableSelector, items => items.Any(o => o.LatestValue.HasValue && equalityCondition(o.Item, o.LatestValue.Value))); + + /// + /// The source to evaluate a condition across any item in. + /// A factory that produces a condition observable for each item. + /// A that predicate applied to each per-item observable's latest value (without the item). + /// This overload accepts a predicate that takes only the value, not the item. Useful when the condition depends only on the observed value. + public static IObservable TrueForAny(this IObservable> source, Func> observableSelector, Func equalityCondition) + where TObject : notnull + where TKey : notnull + where TValue : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + equalityCondition.ThrowArgumentNullExceptionIfNull(nameof(equalityCondition)); + + return source.TrueFor(observableSelector, items => items.Any(o => o.LatestValue.HasValue && equalityCondition(o.LatestValue.Value))); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.UpdateIndex.cs b/src/DynamicData/Cache/ObservableCacheEx.UpdateIndex.cs new file mode 100644 index 000000000..933529ea0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.UpdateIndex.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Sets the Index property on each item (which must implement ) + /// to reflect its position in the sorted output. Operates on . + /// + /// The type of the object. + /// The type of the key. + /// The source to update index positions in. + /// An observable that emits the sorted changesets after updating item indices. + public static IObservable> UpdateIndex(this IObservable> source) + where TObject : IIndexAware + where TKey : notnull => source.Do(changes => changes.SortedItems.Select((update, index) => new { update, index }).ForEach(u => u.update.Value.Index = u.index)); +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs b/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs index d5a4bd979..2dbeaf1e6 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs @@ -8,7 +8,7 @@ namespace DynamicData; /// -/// ObservableCache extensions for the virtualised group of operators. +/// Extensions for dynamic data. /// public static partial class ObservableCacheEx { diff --git a/src/DynamicData/Cache/ObservableCacheEx.Watch.cs b/src/DynamicData/Cache/ObservableCacheEx.Watch.cs new file mode 100644 index 000000000..8cccf6088 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Watch.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Filters the source changeset stream to a single key, emitting each for that key. + /// Changes for all other keys are ignored. + /// + /// The type of the object. + /// The type of the key. + /// The source to watch a single key in. + /// The key to observe. + /// An observable of for the specified key only. + /// + /// + /// Emits Add, Update, Remove, and Refresh changes as they occur for the target key. + /// No initial emission occurs if the key is not yet present in the cache. This operator does not + /// produce changesets; it produces individual change notifications. For Optional-based watching, + /// use . + /// + /// + /// + /// + public static IObservable> Watch(this IObservable> source, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.SelectMany(updates => updates).Where(update => update.Key.Equals(key)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.WatchValue.cs b/src/DynamicData/Cache/ObservableCacheEx.WatchValue.cs new file mode 100644 index 000000000..50929fb3a --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.WatchValue.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Filters the source changeset stream to a single key, emitting the current value each time it changes. + /// Even emits the value on removal (the removed item's value). + /// + /// The type of the object. + /// The type of the key. + /// The source to watch a single key in. + /// The key to observe. + /// An observable of the item's value whenever it changes for the specified key. + /// + /// + /// Unlike , + /// this does not emit on removal. It emits the removed item's value instead. + /// If you need to distinguish presence from absence, use ToObservableOptional. + /// + /// + /// EventBehavior + /// AddEmits the added item's value. + /// UpdateEmits the new value. + /// RemoveEmits the removed item's value (not None; use if you need removal detection). + /// RefreshEmits the current value. + /// + /// Worth noting: No emission occurs if the key is not present at subscription time. Changes to other keys are ignored entirely. + /// + /// + /// + public static IObservable WatchValue(this IObservableCache source, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Watch(key).Select(u => u.Current); + } + + /// + /// The source to watch a single key in. + /// The key to observe. + /// This overload extends IObservable<> instead of . + public static IObservable WatchValue(this IObservable> source, TKey key) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Watch(key).Select(u => u.Current); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs new file mode 100644 index 000000000..15c3196dc --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Emits an item whenever any of its properties change via . + /// Subscribes to PropertyChanged on each cache item using MergeMany. + /// + /// The type of the object (must implement ). + /// The type of the key. + /// The source to observe property changes on items in. + /// The specific property names to monitor. If empty, all property changes trigger emissions. + /// An observable that emits the item itself each time a monitored property changes. + /// + /// + /// Subscriptions are managed per item: created on Add, replaced on Update, disposed on Remove. + /// Errors from individual property subscriptions are silently ignored. The output is not a changeset + /// stream; it is a plain IObservable<TObject?>. If the same item changes multiple properties + /// rapidly, each change emits the item separately (no deduplication). + /// + /// + /// EventBehavior + /// AddSubscribes to PropertyChanged on the new item. + /// UpdateDisposes the old item's subscription and subscribes to the new item. + /// RemoveDisposes the item's PropertyChanged subscription. + /// RefreshNo effect on subscriptions. + /// OnErrorErrors from individual property subscriptions are silently ignored. Source errors terminate the stream. + /// + /// + /// + /// + /// + /// + public static IObservable WhenAnyPropertyChanged(this IObservable> source, params string[] propertiesToMonitor) + where TObject : INotifyPropertyChanged + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeMany(t => t.WhenAnyPropertyChanged(propertiesToMonitor)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs new file mode 100644 index 000000000..9efbb2791 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Emits a (item + property value) whenever the specified property + /// changes on any item in the cache. Subscribes via using MergeMany. + /// + /// The type of the object (must implement ). + /// The type of the key. + /// The type of the monitored property. + /// The source to observe a specific property on items in. + /// A that expression selecting the property to monitor. + /// When (the default), the current property value is emitted immediately for each item upon subscription. + /// An observable of containing both the item and its property value. + /// + /// + /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. Errors from individual + /// property subscriptions are silently ignored. The output is not a changeset stream. If you only need + /// the value (not the owning item), use instead. + /// + /// + /// EventBehavior + /// AddSubscribes to the specified property on the new item. If notifyOnInitialValue is true, the current value is emitted immediately. + /// UpdateDisposes the old item's property subscription and subscribes to the new item. + /// RemoveDisposes the item's property subscription. No further emissions for this item. + /// RefreshNo effect on subscriptions. The existing property subscription continues. + /// OnErrorPer-item property subscription errors are silently ignored. Source errors terminate the stream. + /// + /// + /// + public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) + where TObject : INotifyPropertyChanged + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + return source.MergeMany(t => t.WhenPropertyChanged(propertyAccessor, notifyOnInitialValue)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs new file mode 100644 index 000000000..b27e31c4f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Emits the property value whenever the specified property changes on any item in the cache. + /// Like but emits only the value, discarding the owning item. + /// + /// The type of the object (must implement ). + /// The type of the key. + /// The type of the monitored property. + /// The source to observe a specific property value on items in. + /// A that expression selecting the property to monitor. + /// When (the default), the current property value is emitted immediately for each item upon subscription. + /// An observable of property values. The owning item is not included; use if you need it. + /// + /// + /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. Errors from individual + /// property subscriptions are silently ignored. If you need to correlate a value back to its source item, + /// use which returns a pair. + /// + /// + /// EventBehavior + /// AddSubscribes to the specified property. If notifyOnInitialValue is true, the current value is emitted immediately. + /// UpdateDisposes the old subscription, subscribes to the new item's property. + /// RemoveDisposes the property subscription. + /// RefreshNo effect on subscriptions. + /// OnErrorPer-item errors silently ignored. Source errors terminate the stream. + /// + /// + /// + /// + /// + /// + public static IObservable WhenValueChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) + where TObject : INotifyPropertyChanged + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + return source.MergeMany(t => t.WhenChanged(propertyAccessor, notifyOnInitialValue)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAre.cs b/src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAre.cs new file mode 100644 index 000000000..095e1ec11 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAre.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Includes changes for the specified reasons only. + /// + /// The type of the object. + /// The type of the key. + /// The source to filter by change reason. + /// The values to filter by. + /// An observable which emits a change set with items matching the reasons. + /// reasons. + /// Must select at least on reason. + /// + /// Worth noting: Filtering out Remove changes will cause memory leaks in downstream caches, since items are never cleaned up. + /// + public static IObservable> WhereReasonsAre(this IObservable> source, params ChangeReason[] reasons) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); + + if (reasons.Length == 0) + { + throw new ArgumentException("Must select at least one reason"); + } + + var hashed = new HashSet(reasons); + + return source.Select(updates => new ChangeSet(updates.Where(u => hashed.Contains(u.Reason)))).NotEmpty(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAreNot.cs b/src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAreNot.cs new file mode 100644 index 000000000..7f9bf5bb6 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.WhereReasonsAreNot.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Excludes updates for the specified reasons. + /// + /// The type of the object. + /// The type of the key. + /// The source to filter by excluding change reasons. + /// The values to filter by. + /// An observable which emits a change set with items not matching the reasons. + /// reasons. + /// Must select at least on reason. + /// + /// Worth noting: Filtering out Remove changes will cause memory leaks in downstream caches, since items are never cleaned up. + /// + public static IObservable> WhereReasonsAreNot(this IObservable> source, params ChangeReason[] reasons) + where TObject : notnull + where TKey : notnull + { + reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); + + if (reasons.Length == 0) + { + throw new ArgumentException("Must select at least one reason"); + } + + var hashed = new HashSet(reasons); + + return source.Select(updates => new ChangeSet(updates.Where(u => !hashed.Contains(u.Reason)))).NotEmpty(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Xor.cs b/src/DynamicData/Cache/ObservableCacheEx.Xor.cs new file mode 100644 index 000000000..8b82b1943 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Xor.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2011-2025 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.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using DynamicData.Binding; +using DynamicData.Cache; +using DynamicData.Cache.Internal; + +// ReSharper disable once CheckNamespace + +namespace DynamicData; + +/// +/// Extensions for dynamic data. +/// +public static partial class ObservableCacheEx +{ + /// + /// Combines multiple changeset streams using logical XOR (symmetric difference). + /// An item appears downstream only if it exists in exactly one source. + /// + /// The type of the object. + /// The type of the key. + /// The source to combine. + /// The additional streams to combine with. + /// A changeset stream containing items present in exactly one source. + /// + /// + /// Items are tracked via reference counting. An item appears downstream only when exactly one + /// source holds it. Adding the same key from a second source removes it from the result; + /// removing from that second source restores it. + /// + /// + /// EventBehavior + /// AddIf the key is now held by exactly one source, an Add is emitted. If adding causes the count to reach 2+, a Remove is emitted (the item is no longer exclusive). + /// UpdateIf the item is currently downstream (count is 1), an Update is emitted. + /// RemoveReference count decremented. If the count drops to exactly 1, an Add is emitted (the item is now exclusive to one source). If it drops to 0, a Remove is emitted. + /// RefreshIf the item is downstream, a Refresh is forwarded. + /// + /// + /// or is . + /// + /// + /// + /// + public static IObservable> Xor(this IObservable> source, params IObservable>[] others) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (others is null || others.Length == 0) + { + throw new ArgumentNullException(nameof(others)); + } + + return source.Combine(CombineOperator.Xor, others); + } + + /// + /// The of streams to combine. + /// This overload accepts a pre-built collection of sources instead of a params array. + public static IObservable> Xor(this ICollection>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Xor); + } + + /// + /// Dynamically apply a logical Xor operator between the items in the outer observable list. + /// Items which are only in one of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of streams to combine. + /// An observable which emits a change set. + public static IObservable> Xor(this IObservableList>> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Xor); + } + + /// + /// Dynamically apply a logical Xor operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits a change set. + public static IObservable> Xor(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Xor); + } + + /// + /// Dynamically apply a logical Xor operator between the items in the outer observable list. + /// Items which are in any of the sources are included in the result. + /// + /// The type of the object. + /// The type of the key. + /// The of changeset streams to combine. + /// An observable which emits a change set. + public static IObservable> Xor(this IObservableList> sources) + where TObject : notnull + where TKey : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Combine(CombineOperator.Xor); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index 2f91f6a87..76a0cb1f8 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 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. @@ -17,6 +17,7 @@ using DynamicData.Cache.Internal; // ReSharper disable once CheckNamespace + namespace DynamicData; /// @@ -25,6809 +26,4 @@ namespace DynamicData; public static partial class ObservableCacheEx { private const int DefaultSortResetThreshold = 100; - private const bool DefaultResortOnSourceRefresh = true; - - /// - /// Injects a side effect into the changeset stream by calling . - /// for every changeset, then forwarding it downstream unchanged. - /// - /// The type of items in the cache. - /// The type of the key. - /// The source to observe and adapt. - /// The whose Adapt method is called for each changeset. - /// An observable that emits the same changesets as , after the adaptor has processed each one. - /// - /// - /// This is a thin wrapper around Rx's Do operator. The adaptor receives each changeset - /// as a side effect; the changeset itself is forwarded downstream unmodified. - /// - /// - /// or is . - /// - /// - public static IObservable> Adapt(this IObservable> source, IChangeSetAdaptor adaptor) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); - - return source.Do(adaptor.Adapt); - } - - /// - /// The source to observe and adapt. - /// The whose Adapt method is called for each changeset. - /// This overload operates on . Delegates to Rx's Do operator. - public static IObservable> Adapt(this IObservable> source, ISortedChangeSetAdaptor adaptor) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); - - return source.Do(adaptor.Adapt); - } - - /// - /// Adds or updates the cache with the specified item, producing a changeset with a single Add - /// (if the key is new) or Update (if the key already exists). - /// - /// The type of the object. - /// The type of the key. - /// The to add or update items in. - /// The item to add or update. - /// - /// Convenience method that wraps a single-item mutation inside . - /// - /// EventBehavior - /// AddProduced when the key does not already exist in the cache. - /// UpdateProduced when the key already exists. The previous value is included in the changeset. - /// RemoveNot produced by this method. - /// RefreshNot produced by this method. - /// - /// - /// is . - /// - /// - public static void AddOrUpdate(this ISourceCache source, TObject item) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.AddOrUpdate(item)); - } - - /// - /// The to add or update items in. - /// The item to add or update. - /// The used to determine whether a new item is the same as an existing cached item. When equal, the update is skipped. - /// This overload uses to suppress no-op updates when the new value equals the existing one. - public static void AddOrUpdate(this ISourceCache source, TObject item, IEqualityComparer equalityComparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.AddOrUpdate(item, equalityComparer)); - } - - /// - /// The to add or update items in. - /// The of items to add or update. - /// Batch overload. All items are added/updated inside a single call, producing one changeset. - public static void AddOrUpdate(this ISourceCache source, IEnumerable items) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.AddOrUpdate(items)); - } - - /// - /// The to add or update items in. - /// The of items to add or update. - /// The used to determine whether a new item is the same as an existing cached item. When equal, the update is skipped. - /// Batch overload with equality comparison. All items are added/updated inside a single call. - public static void AddOrUpdate(this ISourceCache source, IEnumerable items, IEqualityComparer equalityComparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.AddOrUpdate(items, equalityComparer)); - } - - /// - /// The to add or update items in. - /// The item to add or update. - /// The key to associate with the item. - /// This overload operates on , which requires an explicit key parameter. - public static void AddOrUpdate(this IIntermediateCache source, TObject item, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - item.ThrowArgumentNullExceptionIfNull(nameof(item)); - - source.Edit(updater => updater.AddOrUpdate(item, key)); - } - - /// - /// Applied a logical And operator between the collections i.e items which are in all of the - /// sources are included. - /// - /// The type of the object. - /// The type of the key. - /// The source to combine. - /// The additional streams to combine with. - /// An observable which emits change sets. - /// source or others. - /// - public static IObservable> And(this IObservable> source, params IObservable>[] others) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return others is null || others.Length == 0 - ? throw new ArgumentNullException(nameof(others)) - : source.Combine(CombineOperator.And, others); - } - - /// - /// Applied a logical And operator between the collections i.e items which are in all of the sources are included. - /// - /// The type of the object. - /// The type of the key. - /// The of streams to combine. - /// An observable which emits change sets. - /// - /// source - /// or - /// others. - /// - public static IObservable> And(this ICollection>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.And); - } - - /// - /// Dynamically apply a logical And operator between the items in the outer observable list. - /// Items which are in all of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of streams to combine. - /// An observable which emits change sets. - public static IObservable> And(this IObservableList>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.And); - } - - /// - /// Dynamically apply a logical And operator between the items in the outer observable list. - /// Items which are in all of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits change sets. - public static IObservable> And(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.And); - } - - /// - /// Dynamically apply a logical And operator between the items in the outer observable list. - /// Items which are in all of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits change sets. - public static IObservable> And(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.And); - } - - /// - /// Wraps an in a read-only facade, hiding the mutable API. - /// - /// The type of the object. - /// The type of the key. - /// The to operate on. - /// A read-only . - /// is . - /// - public static IObservableCache AsObservableCache(this IObservableCache source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new AnonymousObservableCache(source); - } - - /// - /// Materializes a changeset stream into a queryable, read-only . - /// The cache subscribes to the source on first access and maintains a live snapshot of all items. - /// - /// The type of the object. - /// The type of the key. - /// The source to materialize into a read-only cache. - /// If (default), all cache operations are synchronized. Set to when the caller guarantees single-threaded access. - /// A read-only observable cache that reflects the current state of the pipeline. - /// - /// - /// Disposing the returned cache unsubscribes from the source stream. The cache's Connect() - /// method provides a changeset stream of its own, which re-emits the current state on each new subscriber. - /// - /// When is , a is used internally. - /// - /// is . - /// - /// - public static IObservableCache AsObservableCache(this IObservable> source, bool applyLocking = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (applyLocking) - { - return new AnonymousObservableCache(source); - } - - return new LockFreeObservableCache(source); - } - - #if SUPPORTS_ASYNC_DISPOSABLE - /// - /// - /// Disposes items implementing or when they are removed or replaced, - /// and disposes all tracked items when the stream completes, errors, or the subscription is disposed. - /// - /// - /// Individual items are disposed after the changeset has been forwarded downstream, so downstream operators - /// see the removal before disposal occurs. Items implementing neither disposal interface are ignored. - /// - /// - /// The type of items in the cache. - /// The type of the key. - /// The source to track for async disposal on removal. - /// - /// - /// Invoked once per subscription, providing an that signals when all - /// calls have finished. The signal emits a single value - /// and then completes. - /// - /// - /// This is delivered on a separate channel from the main changeset stream so it can be observed even - /// if the source stream errors. - /// - /// - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddTracks the item. No disposal. - /// UpdateDisposes the previous value (if it differs by reference from the current). Tracks the new value. - /// RemoveDisposes the removed item. - /// RefreshPassed through. No disposal. - /// - /// - /// - /// On stream completion, error, or subscription disposal, all items still in the cache are disposed. - /// items are disposed synchronously; items - /// are dispatched via the signal. - /// - /// - /// or is . - /// - public static IObservable> AsyncDisposeMany( - this IObservable> source, - Action> disposalsCompletedAccessor) - where TObject : notnull - where TKey : notnull - => Cache.Internal.AsyncDisposeMany.Create( - source: source, - disposalsCompletedAccessor: disposalsCompletedAccessor); - #endif - - /// - /// Automatically refresh downstream operators when any properties change. - /// - /// The object of the change set. - /// The key of the change set. - /// The source to monitor for property-driven refresh signals. - /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements have successive property changes. - /// An optional throttle applied to each item's property change notifications, preventing excessive refresh invocations. - /// An optional for scheduling work. - /// An observable change set with additional refresh changes. - /// - public static IObservable> AutoRefresh(this IObservable> source, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.AutoRefreshOnObservable( - (t, _) => - { - if (propertyChangeThrottle is null) - { - return t.WhenAnyPropertyChanged(); - } - - return t.WhenAnyPropertyChanged().Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); - }, - changeSetBuffer, - scheduler); - } - - /// - /// Automatically refresh downstream operators when properties change. - /// - /// The object of the change set. - /// The key of the change set. - /// The type of the property. - /// The source to monitor for property-driven refresh signals. - /// A that specify a property to observe changes. When it changes a Refresh is invoked. - /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements have successive property changes. - /// An optional throttle applied to each item's property change notifications, preventing excessive refresh invocations. - /// An optional for scheduling work. - /// An observable change set with additional refresh changes. - public static IObservable> AutoRefresh(this IObservable> source, Expression> propertyAccessor, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.AutoRefreshOnObservable( - (t, _) => - { - if (propertyChangeThrottle is null) - { - return t.WhenPropertyChanged(propertyAccessor, false); - } - - return t.WhenPropertyChanged(propertyAccessor, false).Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); - }, - changeSetBuffer, - scheduler); - } - - /// - /// Automatically refresh downstream operator. The refresh is triggered when the observable receives a notification. - /// - /// The object of the change set. - /// The key of the change set. - /// The type of evaluation. - /// The source to monitor for observable-driven refresh signals. - /// The observable which acts on items within the collection and produces a value when the item should be refreshed. - /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements require a refresh. - /// An optional for scheduling work. - /// An observable change set with additional refresh changes. - /// - public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => source.AutoRefreshOnObservable((t, _) => reevaluator(t), changeSetBuffer, scheduler); - - /// - /// Automatically refresh downstream operator. The refresh is triggered when the observable receives a notification. - /// - /// The object of the change set. - /// The key of the change set. - /// The type of evaluation. - /// The source to monitor for observable-driven refresh signals. - /// The observable which acts on items within the collection and produces a value when the item should be refreshed. - /// An optional buffer duration. Batches multiple refresh signals into a single changeset, improving performance when many elements change in quick succession. This greatly increases performance when many elements require a refresh. - /// An optional for scheduling work. - /// An observable change set with additional refresh changes. - /// - /// Worth noting: Per-item observable errors are silently ignored (not forwarded to the downstream observer). Only source stream errors propagate. - /// - public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); - - return new AutoRefresh(source, reevaluator, changeSetBuffer, scheduler).Run(); - } - - /// - /// Collects changesets emitted within a time window and merges them into a single changeset. - /// Uses Rx's Buffer operator followed by . - /// - /// The type of the object. - /// The type of the key. - /// The source to batch. - /// The time window for batching. - /// The scheduler for timing. Defaults to . - /// An observable that emits merged changesets, one per time window. - /// - /// - /// All changesets received during the time window are concatenated into a single changeset. - /// This is useful for reducing UI update frequency when the source emits many rapid changes. - /// - /// - /// EventBehavior - /// AddBuffered and included in the merged changeset at the end of the time window. - /// UpdateBuffered and included in the merged changeset. - /// RemoveBuffered and included in the merged changeset. - /// RefreshBuffered and included in the merged changeset. - /// OnCompletedAny remaining buffered changes are flushed, then completion is forwarded. - /// - /// Worth noting: The merged changeset may contain contradictory changes (e.g., Add then Remove for the same key). Downstream operators handle this correctly, but raw inspection of the changeset may be surprising. - /// - /// is . - /// - /// - public static IObservable> Batch(this IObservable> source, TimeSpan timeSpan, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Buffer(timeSpan, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult(); - } - - /// - /// This overload delegates to the primary overload with initialPauseState: false. - public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => BatchIf(source, pauseIfTrueSelector, false, scheduler); - - /// - /// This overload delegates to the primary overload with default initialPauseState: false. - public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState = false, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => new BatchIf(source, pauseIfTrueSelector, null, initialPauseState, scheduler: scheduler).Run(); - - /// - /// This overload omits initialPauseState (defaults to ) but accepts a timeout. - public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, TimeSpan? timeOut = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => BatchIf(source, pauseIfTrueSelector, false, timeOut, scheduler); - - /// - /// Conditionally buffers changesets while a pause signal is active, then flushes all buffered - /// changes as a single merged changeset when the signal resumes. - /// - /// The type of the object. - /// The type of the key. - /// The source to conditionally buffer. - /// An that when , buffering begins. When , the buffer is flushed. - /// If , starts in a paused (buffering) state. - /// A that maximum time the buffer stays open. When elapsed, the buffer is flushed regardless of pause state. - /// The for timeout timing. - /// An observable that emits changesets, buffered or passthrough depending on pause state. - /// - /// - /// While paused, incoming changesets are accumulated. On resume (or timeout), all buffered changesets - /// are merged into a single changeset and emitted. While not paused, changesets pass through immediately. - /// - /// - /// EventBehavior - /// AddBuffered while paused; forwarded immediately while active. - /// UpdateBuffered while paused; forwarded immediately while active. - /// RemoveBuffered while paused; forwarded immediately while active. - /// RefreshBuffered while paused; forwarded immediately while active. - /// OnErrorBuffered data is lost. - /// OnCompletedAny remaining buffered data is flushed before completion. - /// - /// Worth noting: If the source completes while paused, buffered data IS flushed before OnCompleted. However, if the source errors while paused, buffered data is lost. - /// - /// or is . - /// - /// - public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState = false, TimeSpan? timeOut = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); - - return new BatchIf(source, pauseIfTrueSelector, timeOut, initialPauseState, scheduler: scheduler).Run(); - } - - /// - /// The source to conditionally buffer. - /// An that controls buffering: begins buffering, flushes the buffer. - /// If , starts in a paused (buffering) state. - /// An optional timer. The buffer is flushed each time the timer produces a value, and buffering ceases when it completes. - /// An optional for scheduling work. - /// This overload accepts an explicit timer observable instead of a timeout. - public static IObservable> BatchIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState = false, IObservable? timer = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => new BatchIf(source, pauseIfTrueSelector, null, initialPauseState, timer, scheduler).Run(); - - /// - /// Binds the results to the specified observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The number of changes before a reset notification is triggered. - /// An observable which will emit change sets. - /// source. - /// - public static IObservable> Bind(this IObservable> source, IObservableCollection destination, int refreshThreshold = BindingOptions.DefaultResetThreshold) - where TObject : notnull - where TKey : notnull - { - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - - var options = refreshThreshold == BindingOptions.DefaultResetThreshold - ? defaults - : defaults with { ResetThreshold = refreshThreshold }; - - return source?.Bind(destination, new ObservableCollectionAdaptor(options)) ?? throw new ArgumentNullException(nameof(source)); - } - - /// - /// Binds the results to the specified observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The that controls binding behavior. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, IObservableCollection destination, BindingOptions options) - where TObject : notnull - where TKey : notnull - { - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source?.Bind(destination, new ObservableCollectionAdaptor(options)) ?? throw new ArgumentNullException(nameof(source)); - } - - /// - /// Binds the results to the specified binding collection using the specified update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The that applies changes to the bound collection. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, IObservableCollection destination, IObservableCollectionAdaptor updater) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - updater.ThrowArgumentNullExceptionIfNull(nameof(updater)); - - return Observable.Create>( - observer => - { - var locker = InternalEx.NewLock(); - return source.Synchronize(locker).Select( - changes => - { - updater.Adapt(changes, destination); - return changes; - }).SubscribeSafe(observer); - }); - } - - /// - /// Binds the results to the specified readonly observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The output that will be populated with the results. - /// The that controls binding behavior. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - var target = new ObservableCollectionExtended(); - readOnlyObservableCollection = new ReadOnlyObservableCollection(target); - return source.Bind(target, new ObservableCollectionAdaptor(options)); - } - - /// - /// Binds the results to the specified readonly observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The output that will be populated with the results. - /// The number of changes before a reset notification is triggered. - /// When , uses Replace instead of Remove/Add for updates in the bound collection. Not all platforms support replace notifications. - /// An optional that controls how the target collection is updated. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold, bool useReplaceForUpdates = BindingOptions.DefaultUseReplaceForUpdates, IObservableCollectionAdaptor? adaptor = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (adaptor is not null) - { - var target = new ObservableCollectionExtended(); - readOnlyObservableCollection = new ReadOnlyObservableCollection(target); - return source.Bind(target, adaptor); - } - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - - var options = resetThreshold == BindingOptions.DefaultResetThreshold && useReplaceForUpdates == BindingOptions.DefaultUseReplaceForUpdates - ? defaults - : defaults with { ResetThreshold = resetThreshold, UseReplaceForUpdates = useReplaceForUpdates }; - - return source.Bind(out readOnlyObservableCollection, options); - } - - /// - /// Binds the results to the specified observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The that will receive the changes. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, IObservableCollection destination) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source.Bind(destination, DynamicDataOptions.Binding); - } - - /// - /// Binds the results to the specified observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The that controls binding behavior. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, IObservableCollection destination, BindingOptions options) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - var updater = new SortedObservableCollectionAdaptor(options); - return source.Bind(destination, updater); - } - - /// - /// Binds the results to the specified binding collection using the specified update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The that applies changes to the bound collection. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, IObservableCollection destination, ISortedObservableCollectionAdaptor updater) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - updater.ThrowArgumentNullExceptionIfNull(nameof(updater)); - - return Observable.Create>( - observer => - { - var locker = InternalEx.NewLock(); - return source.Synchronize(locker).Select( - changes => - { - updater.Adapt(changes, destination); - return changes; - }).SubscribeSafe(observer); - }); - } - - /// - /// Binds the results to the specified readonly observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The output that will be populated with the results. - /// The that controls binding behavior. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - var target = new ObservableCollectionExtended(); - var result = new ReadOnlyObservableCollection(target); - var updater = new SortedObservableCollectionAdaptor(options); - readOnlyObservableCollection = result; - return source.Bind(target, updater); - } - - /// - /// Binds the results to the specified readonly observable collection using the default update algorithm. - /// - /// The type of the object. - /// The type of the key. - /// The source to bind to a collection. - /// The output that will be populated with the results. - /// The number of changes before a reset event is called on the observable collection. - /// When , uses Replace instead of Remove/Add for updates in the bound collection. Not all platforms support replace notifications. - /// An that specify an adaptor to change the algorithm to update the target collection. - /// An observable which will emit change sets. - /// source. - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold, bool useReplaceForUpdates = BindingOptions.DefaultUseReplaceForUpdates, ISortedObservableCollectionAdaptor? adaptor = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - var options = resetThreshold == BindingOptions.DefaultResetThreshold && useReplaceForUpdates == BindingOptions.DefaultUseReplaceForUpdates - ? defaults - : defaults with { ResetThreshold = resetThreshold, UseReplaceForUpdates = useReplaceForUpdates }; - - adaptor ??= new SortedObservableCollectionAdaptor(options); - - var target = new ObservableCollectionExtended(); - readOnlyObservableCollection = new ReadOnlyObservableCollection(target); - return source.Bind(target, adaptor); - } - -#if SUPPORTS_BINDINGLIST - - /// - /// Binds a clone of the observable change set to the target observable collection. - /// - /// The object type. - /// The key type. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The reset threshold. - /// An observable which will emit change sets. - /// - /// source - /// or - /// targetCollection. - /// - public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TObject, TKey>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); - - return source.Adapt(new BindingListAdaptor(bindingList, resetThreshold)); - } - - /// - /// Binds a clone of the observable change set to the target observable collection. - /// - /// The object type. - /// The key type. - /// The source to bind to a collection. - /// The that will receive the changes. - /// The reset threshold. - /// An observable which will emit change sets. - /// - /// source - /// or - /// targetCollection. - /// - public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TObject, TKey>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); - - return source.Adapt(new SortedBindingListAdaptor(bindingList, resetThreshold)); - } - -#endif - - /// - /// Buffers the initial burst of changesets for the specified duration, merges them into a single - /// changeset, then passes all subsequent changesets through without buffering. - /// - /// The object type. - /// The type of the key. - /// The source to buffer during the initial loading period. - /// The time window to buffer, measured from when the first changeset arrives. - /// The scheduler for timing. Defaults to . - /// An observable that emits one merged changeset for the initial burst, then passthrough for the rest. - /// - /// - /// Useful for aggregating the initial snapshot (which may arrive as many small changesets) into a - /// single changeset for efficient downstream processing, while leaving subsequent live updates untouched. - /// - /// Internally uses , Rx Buffer, and . - /// - /// - /// - public static IObservable> BufferInitial(this IObservable> source, TimeSpan initialBuffer, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull => source.DeferUntilLoaded().Publish( - shared => - { - var initial = shared.Buffer(initialBuffer, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult().Take(1); - - return initial.Concat(shared); - }); - - /// - /// Casts each item in the changeset to a new type using the provided converter function. - /// Equivalent to - /// but named for discoverability when a simple type cast or conversion is needed. - /// - /// The type of the source object. - /// The type of the key. - /// The type of the destination object. - /// The source to cast. - /// The conversion function applied to each item. - /// An observable changeset of converted items. - /// - /// - /// EventBehavior - /// AddCalls and emits an Add with the converted item. - /// UpdateCalls on the new value and emits an Update. - /// RemoveEmits a Remove. The converter is not called. - /// RefreshForwarded as Refresh. The converter is not called. - /// - /// - /// - public static IObservable> Cast(this IObservable> source, Func converter) - where TSource : notnull - where TKey : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new Cast(source, converter).Run(); - } - - /// - /// Re-keys each item in the changeset by applying to the current item. - /// The original change reason is preserved; only the key is remapped. - /// - /// The type of the object. - /// The type of the source key. - /// The type of the destination key. - /// The source to re-key. - /// The that computes the destination key from the item, e.g. (item) => item.NewId. - /// An observable changeset with items re-keyed using . - /// - /// - /// EventBehavior - /// Add is called on the item. An Add is emitted with the destination key. - /// Update is called on the current item. An Update is emitted with the destination key. If the key selector produces a different destination key for the updated value than it did for the original value, downstream consumers will see an Update for a key that may not match the original Add. - /// Remove is called on the item. A Remove is emitted with the destination key. - /// Refresh is called on the item. A Refresh is emitted with the destination key. - /// - /// - /// - public static IObservable> ChangeKey(this IObservable> source, Func keySelector) - where TObject : notnull - where TSourceKey : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return source.Select( - updates => - { - var changed = updates.Select(u => new Change(u.Reason, keySelector(u.Current), u.Current, u.Previous)); - return new ChangeSet(changed); - }); - } - - /// - /// - /// This overload also provides the source key to , - /// allowing the destination key to be derived from both the item and its original key. - /// - public static IObservable> ChangeKey(this IObservable> source, Func keySelector) - where TObject : notnull - where TSourceKey : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return source.Select( - updates => - { - var changed = updates.Select(u => new Change(u.Reason, keySelector(u.Key, u.Current), u.Current, u.Previous)); - return new ChangeSet(changed); - }); - } - - /// - /// Removes all items from the cache, producing a changeset with a Remove for every item. - /// - /// The type of the object. - /// The type of the key. - /// The to clear. - /// - /// - /// EventBehavior - /// AddNot produced by this operation. - /// UpdateNot produced by this operation. - /// RemoveA Remove is emitted for every item currently in the cache. - /// RefreshNot produced by this operation. - /// - /// - /// is . - public static void Clear(this ISourceCache source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Clear()); - } - - /// - public static void Clear(this IIntermediateCache source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Clear()); - } - - /// - public static void Clear(this LockFreeObservableCache source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - source.Edit(updater => updater.Clear()); - } - - /// - /// Applies each change from the source changeset to the specified collection as a side effect. - /// The changeset is forwarded downstream unchanged. - /// - /// The type of the object. - /// The type of the key. - /// The source to clone. - /// The target collection to which changes are applied. - /// An observable that forwards all changesets from unchanged. - /// - /// - /// EventBehavior - /// AddThe item is added to . Forwarded as Add. - /// UpdateThe previous item is removed from and the current item is added. Forwarded as Update. - /// RemoveThe item is removed from . Forwarded as Remove. - /// RefreshIgnored ( has no concept of refresh). Forwarded as Refresh. - /// - /// - public static IObservable> Clone(this IObservable> source, ICollection target) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - target.ThrowArgumentNullExceptionIfNull(nameof(target)); - - return source.Do( - changes => - { - foreach (var item in changes.ToConcreteType()) - { - switch (item.Reason) - { - case ChangeReason.Add: - { - target.Add(item.Current); - } - - break; - - case ChangeReason.Update: - { - target.Remove(item.Previous.Value); - target.Add(item.Current); - } - - break; - - case ChangeReason.Remove: - target.Remove(item.Current); - break; - } - } - }); - } - - /// - /// Obsolete: use instead. - /// - /// The type of the object. - /// The type of the key. - /// The type of the destination. - /// The source to convert. - /// The conversion factory. - /// An observable which emits change sets. - [Obsolete("This was an experiment that did not work. Use Transform instead")] - public static IObservable> Convert(this IObservable> source, Func conversionFactory) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); - - return source.Select( - changes => - { - var transformed = changes.Select(change => new Change(change.Reason, change.Key, conversionFactory(change.Current), change.Previous.Convert(conversionFactory), change.CurrentIndex, change.PreviousIndex)); - return new ChangeSet(transformed); - }); - } - - /// - /// Suppresses all emissions until the first non-empty changeset arrives, then replays that changeset and all subsequent ones. - /// If the source never produces a non-empty changeset, the stream waits indefinitely. - /// - /// The type of the object. - /// The type of the key. - /// The source to defer until the first changeset arrives. - /// An observable that begins emitting changesets once the first non-empty changeset is received. - /// - /// Worth noting: Blocks indefinitely if the cache or stream never receives any data. Ensure the source will eventually emit at least one changeset. - /// - /// - public static IObservable> DeferUntilLoaded(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DeferUntilLoaded(source).Run(); - } - - /// - public static IObservable> DeferUntilLoaded(this IObservableCache source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DeferUntilLoaded(source).Run(); - } - - /// - /// - /// Disposes items implementing when they are removed or replaced, - /// and disposes all tracked items when the stream completes, errors, or the subscription is disposed. - /// - /// - /// Individual items are disposed after the changeset has been forwarded downstream, so downstream operators - /// see the removal before disposal occurs. Items that do not implement are ignored. - /// - /// - /// The type of the object. - /// The type of the key. - /// The source to track for disposal on removal. - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddTracks the item. No disposal. - /// UpdateDisposes the previous value (if it differs by reference from the current). Tracks the new value. - /// RemoveDisposes the removed item. - /// RefreshPassed through. No disposal. - /// - /// - /// - /// On stream completion, error, or subscription disposal, all remaining tracked items are disposed. - /// All disposal is synchronous via . - /// For items that implement , use instead. - /// - /// - /// is . - /// - /// - /// - public static IObservable> DisposeMany(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DisposeMany(source).Run(); - } - - /// - /// Selects distinct values from the source. - /// - /// The type object from which the distinct values are selected. - /// The type of the key. - /// The type of the value. - /// The source to extract distinct values. - /// The value selector. - /// An observable which will emit distinct change sets. - /// - /// Due to it's nature only adds or removes can be returned. - /// Worth noting: Reference counting assumes value equality is transitive. Mutable value objects with inconsistent Equals implementations can corrupt ref counts. - /// - /// source. - /// - public static IObservable> DistinctValues(this IObservable> source, Func valueSelector) - where TObject : notnull - where TKey : notnull - where TValue : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - valueSelector.ThrowArgumentNullExceptionIfNull(nameof(valueSelector)); - - return Observable.Create>(observer => new DistinctCalculator(source, valueSelector).Run().SubscribeSafe(observer)); - } - - /// - /// The to diff and update. - /// The representing the complete desired state to diff against the cache. - /// An used to determine whether a new item is the same as an existing cached item. - /// - /// This overload uses an instead of a delegate - /// to determine item equality. - /// - public static void EditDiff(this ISourceCache source, IEnumerable allItems, IEqualityComparer equalityComparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - allItems.ThrowArgumentNullExceptionIfNull(nameof(allItems)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - - source.EditDiff(allItems, equalityComparer.Equals); - } - - /// - /// Diffs a complete snapshot of items against the current cache contents, producing the minimal set of - /// Add, Update, and Remove changes needed to bring the cache in sync with the snapshot. - /// - /// The type of the object. - /// The type of the key. - /// The to diff and update. - /// The representing the complete desired state. - /// The that returns when the current and previous items are considered equal, e.g. (current, previous) => current.Version == previous.Version. - /// - /// - /// EventBehavior - /// AddItems in whose key is not in the cache produce an Add. - /// UpdateItems present in both and the cache that differ (per ) produce an Update. - /// RemoveItems in the cache whose key is not in produce a Remove. - /// RefreshNot produced by this operation. - /// - /// - /// , , or is . - public static void EditDiff(this ISourceCache source, IEnumerable allItems, Func areItemsEqual) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - allItems.ThrowArgumentNullExceptionIfNull(nameof(allItems)); - areItemsEqual.ThrowArgumentNullExceptionIfNull(nameof(areItemsEqual)); - - var editDiff = new EditDiff(source, areItemsEqual); - editDiff.Edit(allItems); - } - - /// - /// Converts an of into a changeset stream by diffing each - /// emission against the previous one. Each emission replaces the entire dataset. - /// Counterpart to . - /// - /// The type of the object. - /// The type of the key. - /// The source to convert into a keyed changeset stream. - /// The that extracts the unique key from each item. - /// An optional for comparing items. Uses default equality if . - /// An observable changeset representing the incremental differences between successive snapshots. - /// - /// - /// EventBehavior - /// AddItems in the new snapshot whose key was not in the previous snapshot produce an Add. - /// UpdateItems present in both snapshots that differ (per ) produce an Update. - /// RemoveItems in the previous snapshot whose key is absent from the new snapshot produce a Remove. - /// RefreshNot produced by this operator. - /// - /// - /// or is . - /// - public static IObservable> EditDiff(this IObservable> source, Func keySelector, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return new EditDiffChangeSet(source, keySelector, equalityComparer).Run(); - } - - /// - /// Converts an of into a changeset stream that tracks - /// a single item: Some produces an Add or Update, and None produces a Remove. - /// - /// The type of the object. - /// The type of the key. - /// The source to convert into a keyed changeset stream. - /// The that extracts the unique key from each item. - /// An optional for comparing items. Uses default equality if . - /// An observable changeset tracking the single optional item. - /// - /// - /// EventBehavior - /// AddEmitted when the source produces Some(value) and no item was previously tracked. - /// UpdateEmitted when the source produces Some(value) and an item was already tracked with a different value (per ). - /// RemoveEmitted when the source produces None and an item was previously tracked. - /// RefreshNot produced by this operator. - /// - /// - /// or is . - public static IObservable> EditDiff(this IObservable> source, Func keySelector, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return new EditDiffChangeSetOptional(source, keySelector, equalityComparer).Run(); - } - - /// - /// Validates that each changeset contains no duplicate keys. - /// If duplicates are detected, an is emitted via OnError. - /// - /// The type of the object. - /// The type of the key. - /// The source to validate for unique keys. - /// A changeset stream guaranteed to contain unique keys per changeset. - /// - /// - /// EventBehavior - /// AddForwarded as Add if the key is unique within the changeset. - /// UpdateForwarded as Update if the key is unique within the changeset. - /// RemoveForwarded as Remove if the key is unique within the changeset. - /// RefreshForwarded as Refresh if the key is unique within the changeset. - /// OnErrorAlso emitted with if duplicate keys are detected in a changeset. - /// - /// - public static IObservable> EnsureUniqueKeys(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new UniquenessEnforcer(source).Run(); - } - - /// - /// Dynamically apply a logical Except operator between the collections - /// Items from the first collection in the outer list are included unless contained in any of the other lists. - /// - /// The type of the object. - /// The type of the key. - /// The source to combine. - /// The additional streams to combine with. - /// An observable which emits change sets. - /// - /// source - /// or - /// others. - /// - /// - public static IObservable> Except(this IObservable> source, params IObservable>[] others) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (others is null || others.Length == 0) - { - throw new ArgumentNullException(nameof(others)); - } - - return source.Combine(CombineOperator.Except, others); - } - - /// - /// Dynamically apply a logical Except operator between the collections - /// Items from the first collection in the outer list are included unless contained in any of the other lists. - /// - /// The type of the object. - /// The type of the key. - /// The of streams to combine. - /// An observable which emits change sets. - /// - /// source - /// or - /// others. - /// - public static IObservable> Except(this ICollection>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Except); - } - - /// - /// Dynamically apply a logical Except operator between the collections - /// Items from the first collection in the outer list are included unless contained in any of the other lists. - /// - /// The type of the object. - /// The type of the key. - /// The of streams to combine. - /// An observable which emits change sets. - public static IObservable> Except(this IObservableList>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Except); - } - - /// - /// Dynamically apply a logical Except operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits change sets. - public static IObservable> Except(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Except); - } - - /// - /// Dynamically apply a logical Except operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits change sets. - public static IObservable> Except(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Except); - } - - /// - /// Schedules automatic removal of items after the timeout returned by . - /// If returns , the item never expires. - /// - /// The type of the object. - /// The type of the key. - /// The source to apply time-based expiration to. - /// An optional that returns the expiration timeout for each item, or for no expiration. - /// An observable changeset that includes timer-driven Remove changes for expired items. - /// - /// When a timer fires, a Remove is emitted for the expired item. - /// - /// EventBehavior - /// AddSchedules a removal timer based on . Forwarded as Add. - /// UpdateResets the removal timer for the item. Forwarded as Update. - /// RemoveCancels the removal timer. Forwarded as Remove. - /// RefreshForwarded as Refresh. No timer change. - /// OnErrorAll pending timers are cancelled. - /// OnCompletedAll pending timers are cancelled. - /// - /// Worth noting: A return from means "never expire". Update changes reset the expiration timer. - /// - /// or is . - public static IObservable> ExpireAfter( - this IObservable> source, - Func timeSelector) - where TObject : notnull - where TKey : notnull - => Cache.Internal.ExpireAfter.ForStream.Create( - source: source, - timeSelector: timeSelector); - - /// - /// The source to apply time-based expiration to. - /// An optional that returns the expiration timeout for each item, or for no expiration. - /// The used to schedule expiration timers. - public static IObservable> ExpireAfter( - this IObservable> source, - Func timeSelector, - IScheduler scheduler) - where TObject : notnull - where TKey : notnull - => Cache.Internal.ExpireAfter.ForStream.Create( - source: source, - timeSelector: timeSelector, - scheduler: scheduler); - - /// - /// The source to apply time-based expiration to. - /// An optional that returns the expiration timeout for each item, or for no expiration. - /// An optional polling interval. If specified, items are expired on a polling interval rather than per-item timers. Less accurate but more efficient when many items share similar expiration times. - /// - /// This overload uses periodic polling instead of per-item timers. Expired items are removed on the next - /// poll after their timeout elapses, which trades accuracy for reduced timer overhead. - /// - public static IObservable> ExpireAfter( - this IObservable> source, - Func timeSelector, - TimeSpan? pollingInterval) - where TObject : notnull - where TKey : notnull - => Cache.Internal.ExpireAfter.ForStream.Create( - source: source, - timeSelector: timeSelector, - pollingInterval: pollingInterval); - - /// - /// The source to apply time-based expiration to. - /// An optional that returns the expiration timeout for each item, or for no expiration. - /// An optional if specified, items are expired on a polling interval rather than per-item timers. - /// The used to schedule polling and expiration timers. - public static IObservable> ExpireAfter( - this IObservable> source, - Func timeSelector, - TimeSpan? pollingInterval, - IScheduler scheduler) - where TObject : notnull - where TKey : notnull - => Cache.Internal.ExpireAfter.ForStream.Create( - source: source, - timeSelector: timeSelector, - pollingInterval: pollingInterval, - scheduler: scheduler); - - /// - /// Automatically removes items from the after the timeout returned - /// by . Returns an observable of the removed key-value pairs (not a changeset stream). - /// - /// The type of the object. - /// The type of the key. - /// The to operate on. - /// An optional that returns the expiration timeout for each item, or for no expiration. - /// An optional if specified, items are expired on a polling interval rather than per-item timers. - /// The scheduler used to schedule expiration timers. Defaults to if . - /// An observable that emits the key-value pairs of items removed from the cache by expiration. - /// - /// Unlike the stream-based overloads, this operates directly on the - /// and returns the removed items as collections, - /// not as a changeset stream. - /// - /// or is . - public static IObservable>> ExpireAfter( - this ISourceCache source, - Func timeSelector, - TimeSpan? pollingInterval = null, - IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - => Cache.Internal.ExpireAfter.ForSource.Create( - source: source, - timeSelector: timeSelector, - pollingInterval: pollingInterval, - scheduler: scheduler); - - /// - /// Filters items from the source changeset stream using a static predicate. - /// Only items that satisfy are included downstream. - /// - /// The type of the object. - /// The type of the key. - /// The source to filter. - /// The predicate used to determine whether each item is included. - /// When (default), empty changesets are suppressed for performance. Set to to emit empty changesets, which can be useful for monitoring loading status. - /// An observable changeset containing only items that satisfy . - /// - /// - /// EventBehavior - /// AddThe predicate is evaluated. If it passes, an Add is emitted. Otherwise the item is dropped. - /// UpdateFour outcomes: if both old and new values pass, an Update is emitted. If only the new value passes, an Add is emitted. If only the old value passed, a Remove is emitted. If neither passes, the change is dropped. - /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. - /// RefreshThe predicate is re-evaluated. If the item now passes but previously did not, an Add is emitted. If it still passes, a Refresh is forwarded. If it no longer passes, a Remove is emitted. If it still fails, the change is dropped. - /// - /// Worth noting: Refresh events trigger re-evaluation, which can promote or demote items. Pair with for property-change-driven filtering. - /// - /// - /// - /// - public static IObservable> Filter( - this IObservable> source, - Func filter, - bool suppressEmptyChangeSets = true) - where TObject : notnull - where TKey : notnull - => Cache.Internal.Filter.Static.Create( - source: source, - filter: filter, - suppressEmptyChangeSets: suppressEmptyChangeSets); - - /// - /// - /// This overload does not accept a reapplyFilter signal. It is equivalent to calling the - /// full dynamic overload with as the reapply observable. - /// - public static IObservable> Filter( - this IObservable> source, - IObservable> predicateChanged, - bool suppressEmptyChangeSets = true) - where TObject : notnull - where TKey : notnull - => source.Filter( - predicateChanged: predicateChanged, - reapplyFilter: Observable.Empty(), - suppressEmptyChangeSets: suppressEmptyChangeSets); - - /// - /// Creates a dynamically filtered stream where the filter predicate depends on external state. - /// Each emission from triggers a full re-filtering of all items. - /// - /// The type of the object. - /// The type of the key. - /// The type of state value required by . - /// The source to filter. - /// The stream of state values to be passed to . - /// The predicate that receives the current state and an item, returning to include or to exclude. - /// When (default), empty changesets are suppressed for performance. Set to to emit empty changesets. - /// An observable changeset containing only items satisfying for the latest state. - /// , , or is . - /// - /// - /// should emit an initial value immediately upon subscription. - /// Until the first state value arrives, no items pass the filter (all items are excluded). - /// Each subsequent state emission triggers a full re-evaluation of every item in the collection. - /// - /// - /// EventBehavior - /// AddEvaluated against the current state. If it passes, an Add is emitted. Otherwise dropped. - /// UpdateRe-evaluated. Four outcomes as with the static overload. - /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. - /// RefreshRe-evaluated against the current state. May produce Add, Refresh, Remove, or be dropped. - /// - /// Worth noting: should emit an initial value immediately. Each emission triggers a full re-evaluation of all items, which can be expensive for large collections. - /// - public static IObservable> Filter( - this IObservable> source, - IObservable predicateState, - Func predicate, - bool suppressEmptyChangeSets = true) - where TObject : notnull - where TKey : notnull - => Cache.Internal.Filter.Dynamic.Create( - source: source, - predicateState: predicateState, - predicate: predicate, - reapplyFilter: Observable.Empty(), - suppressEmptyChangeSets: suppressEmptyChangeSets); - - /// - /// The source to filter. - /// The that emits new predicates. Each emission replaces the current predicate and triggers a full re-evaluation of all items. - /// The that, when it emits, triggers a full re-evaluation of all items against the current predicate. Useful when filtering on mutable item properties. - /// When (default), empty changesets are suppressed for performance. - /// - /// In addition to the per-item behavior described in the static overload, - /// emissions from replace the predicate and trigger full re-filtering, - /// while emissions from re-evaluate all items against the current predicate. - /// Worth noting: No items are included until the predicate observable emits its first value. - /// - public static IObservable> Filter( - this IObservable> source, - IObservable> predicateChanged, - IObservable reapplyFilter, - bool suppressEmptyChangeSets = true) - where TObject : notnull - where TKey : notnull - - => Cache.Internal.Filter.Dynamic>.Create( - source: source, - predicateState: predicateChanged, - predicate: static (predicate, item) => predicate.Invoke(item), - reapplyFilter: reapplyFilter, - suppressEmptyChangeSets: suppressEmptyChangeSets); - - /// - /// Creates a filtered stream, optimized for stateless/deterministic filtering of immutable items. - /// - /// The type of collection items to be filtered. - /// The type of the key values of each collection item. - /// The source to filter (items assumed immutable). - /// The filtering predicate to be applied to each item. - /// A flag indicating whether the created stream should emit empty changesets. Empty changesets are suppressed by default, for performance. Set to ensure that a downstream changeset occurs for every upstream changeset. - /// A stream of collection changesets where upstream collection items are filtered by the given predicate. - /// - /// The goal of this operator is to optimize a common use-case of reactive programming, where data values flowing through a stream are immutable, and state changes are distributed by publishing new immutable items as replacements, instead of mutating the items directly. - /// In addition to assuming that all collection items are immutable, this operator also assumes that the given filter predicate is deterministic, such that the result it returns will always be the same each time a specific input is passed to it. In other words, the predicate itself also contains no mutable state. - /// Under these assumptions, this operator can bypass the need to keep track of every collection item that passes through it, which the normal operator must do, in order to re-evaluate the filtering status of items, during a refresh operation. - /// Consider using this operator when the following are true: - /// - /// Your collection items are immutable, and changes are published by replacing entire items - /// Your filtering logic does not change over the lifetime of the stream, only the items do - /// Your filtering predicate runs quickly, and does not heavily allocate memory - /// - /// Note that, because filtering is purely deterministic, Refresh operations are transparently ignored by this operator. - /// - /// EventBehavior - /// AddThe predicate is evaluated. If it passes, an Add is emitted. Otherwise the item is dropped. - /// UpdateFour outcomes: if both old and new values pass, an Update is emitted. If only the new value passes, an Add is emitted. If only the old value passed, a Remove is emitted. If neither passes, the change is dropped. - /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. - /// RefreshDropped. Because items are assumed immutable, there is nothing to re-evaluate. - /// - /// - public static IObservable> FilterImmutable( - this IObservable> source, - Func predicate, - bool suppressEmptyChangeSets = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); - - return new FilterImmutable( - predicate: predicate, - source: source, - suppressEmptyChangeSets: suppressEmptyChangeSets) - .Run(); - } - - /// - /// Filters items using a per-item that controls inclusion. - /// Each item's observable is created by and toggles the item in or out of the downstream stream. - /// - /// The type of the object. - /// The type of the key. - /// The source to filter using per-item observables. - /// A factory that creates an for each item and its key. When the observable emits , the item is included; when , it is excluded. - /// A that optional time window to buffer inclusion changes from per-item observables before re-evaluating. - /// An that optional scheduler used for buffering. - /// An observable changeset containing only items whose per-item observable most recently emitted . - /// - /// - /// Source changeset handling (parent events): - /// - /// - /// EventBehavior - /// AddSubscribes to the per-item observable. The item is not included downstream until the observable emits its first . - /// UpdateDisposes the old item's observable subscription and subscribes to the new item's observable. Inclusion state is reset; the new observable must emit before the item reappears. - /// RemoveDisposes the item's observable subscription. If the item was included downstream, a Remove is emitted. - /// RefreshForwarded as Refresh if the item is currently included downstream. Otherwise dropped. - /// - /// - /// Per-item observable handling (filter observable events): - /// - /// - /// EmissionBehavior - /// First The item is included: an Add is emitted downstream. - /// (was included)The item is excluded: a Remove is emitted downstream. - /// (was excluded)The item is re-included: an Add is emitted downstream. - /// (was included)No effect (already included). - /// (was excluded)No effect (already excluded). - /// ErrorTerminates the entire output stream. - /// CompletedThe item remains in its current inclusion state. No further toggling is possible for this item. - /// - /// - /// Worth noting: Items are invisible downstream until their per-item observable emits at least one . - /// If an item's observable never emits, the item never appears. The parameter batches - /// rapid inclusion changes from per-item observables into a single re-evaluation, reducing changeset chatter. - /// - /// - /// or is . - /// - /// - public static IObservable> FilterOnObservable(this IObservable> source, Func> filterFactory, TimeSpan? buffer = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - filterFactory.ThrowArgumentNullExceptionIfNull(nameof(filterFactory)); - - return new FilterOnObservable(source, filterFactory, buffer, scheduler).Run(); - } - - /// - /// - /// This overload does not provide the key to ; only the item is passed. - /// - public static IObservable> FilterOnObservable(this IObservable> source, Func> filterFactory, TimeSpan? buffer = null, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - filterFactory.ThrowArgumentNullExceptionIfNull(nameof(filterFactory)); - - return source.FilterOnObservable((obj, _) => filterFactory(obj), buffer, scheduler); - } - - /// - /// Obsolete: do not use. This can cause unhandled exception issues. Use the standard Rx Finally operator instead. - /// - /// The type contained within the observables. - /// The source to attach a finally action to. - /// The to invoke when the subscription terminates. - /// An observable which has always a finally action applied. - [Obsolete("This can cause unhandled exception issues so do not use")] - public static IObservable FinallySafe(this IObservable source, Action finallyAction) - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - finallyAction.ThrowArgumentNullExceptionIfNull(nameof(finallyAction)); - - return new FinallySafe(source, finallyAction).Run(); - } - - /// - /// Unwraps each into individual - /// values via . - /// - /// The type of the object. - /// The type of the key. - /// The source to flatten into individual changes. - /// An observable of individual values. - /// is . - /// - public static IObservable> Flatten(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.SelectMany(changes => changes); - } - - /// - /// Merges a list of changesets (typically from an Rx Buffer operation) into a single changeset - /// by concatenating all changes. Empty buffers are filtered out. - /// - /// The type of the object. - /// The type of the key. - /// The source to flatten. - /// An observable changeset combining all changes from each buffer into a single emission. - /// is . - public static IObservable> FlattenBufferResult(this IObservable>> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Where(x => x.Count != 0).Select(updates => new ChangeSet(updates.SelectMany(u => u))); - } - - /// - /// Invokes for every individual in each changeset, - /// regardless of change reason. The changeset is forwarded downstream unchanged. - /// - /// The type of the object. - /// The type of the key. - /// The source to observe each individual change in. - /// The action to invoke for each change. Receives the full struct, including , , , and . - /// A stream that forwards all changesets from unchanged. - /// - /// - /// All change reasons (Add, Update, Remove, Refresh) trigger the callback. - /// Use , - /// , - /// , or - /// - /// to target a specific reason. - /// - /// - /// Implemented via Rx's Do operator on the changeset stream. - /// Exceptions thrown in propagate as OnError to the subscriber. No try-catch is applied. - /// - /// - /// or is . - /// - public static IObservable> ForEachChange(this IObservable> source, Action> action) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - action.ThrowArgumentNullExceptionIfNull(nameof(action)); - - return source.Do(changes => changes.ForEach(action)); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the optional left and right values into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> FullJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, Optional, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.FullJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Joins two changeset streams, producing a result for every key that appears on either side (or both). - /// Both sides are because a given key may only exist on one side at any point. - /// Equivalent to SQL FULL OUTER JOIN. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the key, optional left, and optional right into a destination object. Example: (key, left, right) => new Result(key, left, right). - /// An observable changeset keyed by . - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddEmits with the left value and the matching right (or if no right exists). - /// UpdateRe-invokes with the new left value and current right (if any). - /// RemoveIf a right match still exists, re-invokes the selector with left as . If neither side remains, removes the joined result. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddEmits with the matching left (or ) and the right value. - /// UpdateRe-invokes selector with current left (if any) and the new right value. - /// RemoveIf a left match still exists, re-invokes the selector with right as . If neither side remains, removes the joined result. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> FullJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, Optional, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new FullJoin(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the optional left value and the right group into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> FullJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.FullJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Groups right-side items by their mapped key, then full-joins each group to the left source. - /// A result is produced for every key that appears on either side (or both). The left value is - /// because only the right side may have entries for a given key. - /// Equivalent to SQL FULL OUTER JOIN with the right side grouped. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the key, optional left value, and the right group into a destination object. Example: (key, left, group) => new Result(key, left, group). - /// An observable changeset keyed by . - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddEmits with the left value and the current right group for that key (may be empty). - /// UpdateRe-invokes with the new left value and current right group. - /// RemoveIf the right group is non-empty, re-invokes with left as . If both sides are empty, removes the result. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddUpdates the right group, then re-invokes selector with the current left (if any) and the updated group. - /// UpdateUpdates the right group and re-invokes selector. - /// RemoveUpdates the right group. If the group becomes empty and no left exists, removes the result. Otherwise re-invokes selector. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> FullJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new FullJoinMany(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// Groups items from the source changeset, producing groups only for group keys present in . - /// Useful for parent-child relationships where parents and children come from different streams. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group. - /// The group selector factory. - /// An of used to determine which groups appear in the result. - /// - /// Useful for parent-child collection when the parent and child are soured from different streams. - /// - /// An observable which will emit group change sets. - public static IObservable> Group(this IObservable> source, Func groupSelector, IObservable> resultGroupSource) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - groupSelector.ThrowArgumentNullExceptionIfNull(nameof(groupSelector)); - resultGroupSource.ThrowArgumentNullExceptionIfNull(nameof(resultGroupSource)); - - return new SpecifiedGrouper(source, groupSelector, resultGroupSource).Run(); - } - - /// - /// Groups items from the source changeset by a key extracted via . - /// Each group is an observable sub-cache that receives changes for its members. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group. - /// A that extracts the group key from each item. - /// An observable that emits group changesets. Each group exposes a sub-cache of its members. - /// - /// - /// Items are assigned to groups based on the value returned by . - /// Groups are created on demand when the first item is assigned, and removed when their last member is removed. - /// - /// - /// EventBehavior - /// AddThe group key is evaluated. The item is added to the corresponding group (creating the group if new). An Add is emitted to the group's sub-cache. - /// UpdateThe group key is re-evaluated. If unchanged, an Update is emitted within the same group. If the key changed, the item is removed from the old group (emitting Remove) and added to the new group (emitting Add). An empty old group is removed. - /// RemoveThe item is removed from its group. If the group becomes empty, the group itself is removed from the output. - /// RefreshThe group key is re-evaluated. If unchanged, a Refresh is forwarded within the group. If the key changed, the item moves between groups (Remove from old, Add to new). - /// - /// - /// Worth noting: Each group is a live sub-cache that can be subscribed to independently. Subscribers - /// to a group receive only changes for items in that group. When a group is removed (becomes empty), - /// its sub-cache completes. - /// - /// - /// - /// - /// - public static IObservable> Group(this IObservable> source, Func groupSelectorKey) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); - - return new GroupOn(source, groupSelectorKey, null).Run(); - } - - /// - /// The source to group. - /// A that extracts the group key from each item. - /// An that, when it emits, all items are re-evaluated against the group selector, potentially moving items between groups. - /// An observable that emits group changesets. - /// This overload adds a signal. When it fires, every item in the cache is re-grouped using the current selector, which is useful when the grouping depends on mutable item state. - public static IObservable> Group(this IObservable> source, Func groupSelectorKey, IObservable regrouper) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); - regrouper.ThrowArgumentNullExceptionIfNull(nameof(regrouper)); - - return new GroupOn(source, groupSelectorKey, regrouper).Run(); - } - - /// - /// Groups items using a dynamically changing group selector function. - /// Each time emits a new selector, all items are re-grouped. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group. - /// The that emits group selector functions. Each emission triggers a full re-grouping of all items. - /// An that optional signal to force re-evaluation of all items against the current selector. - /// An observable that emits group changesets. - /// - /// - /// Unlike the static-selector overload, this accepts an observable of selector functions. When a new selector - /// arrives, every item is re-evaluated and may move between groups. The optional - /// signal triggers re-evaluation without changing the selector (useful when item properties that affect grouping change). - /// - /// - /// EventBehavior - /// AddThe current selector determines the group. Item is added to the group (group created if new). - /// UpdateGroup key re-evaluated. Item may move between groups if the key changed. - /// RemoveItem removed from its group. Empty groups are removed. - /// RefreshGroup key re-evaluated. Item may move between groups. - /// - /// - /// - /// - public static IObservable> Group(this IObservable> source, IObservable> groupSelectorKeyObservable, IObservable? regrouper = null) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - groupSelectorKeyObservable.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKeyObservable)); - - return new GroupOnDynamic(source, groupSelectorKeyObservable, regrouper).Run(); - } - - /// - /// The source to group. - /// The of selector functions that take only the item (not the key). - /// An optional signal to force re-evaluation. - /// This overload accepts a selector that does not receive the key. Delegates to the overload accepting Func<TObject, TKey, TGroupKey>. - public static IObservable> Group(this IObservable> source, IObservable> groupSelectorKeyObservable, IObservable? regrouper = null) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - groupSelectorKeyObservable.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKeyObservable)); - - return source.Group(groupSelectorKeyObservable.Select(AdaptSelector), regrouper); - } - - /// - /// Groups items where each item's group key is determined by a per-item observable. - /// The observable is created by for each item. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group using per-item observables. - /// A factory that creates a group key observable for each item and its key. - /// An observable that emits group changesets. Each group is a live sub-cache of its members. - /// - /// - /// Unlike which evaluates - /// the group key synchronously, this operator defers group assignment until the per-item observable emits. - /// - /// - /// Source changeset handling (parent events): - /// - /// - /// EventBehavior - /// AddSubscribes to the per-item group key observable. The item is not placed in any group until the observable emits its first group key. - /// UpdateDisposes the old item's group key subscription and subscribes to the new item's observable. The item is removed from its current group until the new observable emits. - /// RemoveDisposes the item's group key subscription. The item is removed from its current group. Empty groups are removed. - /// RefreshNo effect on subscriptions. The item remains in its current group. - /// - /// - /// Per-item observable handling (group key observable events): - /// - /// - /// EmissionBehavior - /// First valueThe item is placed into the group matching the emitted key. An Add appears in that group's sub-cache. If the group is new, the group itself is added to the output. - /// New value (different key)The item moves: Remove from the old group, Add to the new group. If the old group becomes empty, it is removed from the output. - /// Same value (unchanged key)No effect (filtered by DistinctUntilChanged). - /// ErrorTerminates the entire output stream. - /// CompletedThe item remains in its current group. No further group key changes are possible for this item. - /// - /// - /// Worth noting: Items are invisible (not in any group) until their per-item observable emits at least one - /// group key. If an item's observable never emits, the item never appears in any group. Per-item observable errors - /// terminate the entire stream. The output completes when the source completes and all per-item observables have - /// also completed. - /// - /// - /// - /// - /// - /// - public static IObservable> GroupOnObservable(this IObservable> source, Func> groupObservableSelector) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - groupObservableSelector.ThrowArgumentNullExceptionIfNull(nameof(groupObservableSelector)); - - return new GroupOnObservable(source, groupObservableSelector).Run(); - } - - /// - /// Groups the source by the latest value from their observable created by the given factory. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group using per-item observables. - /// The group selector key. - /// An observable which will emit group change sets. - public static IObservable> GroupOnObservable(this IObservable> source, Func> groupObservableSelector) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - groupObservableSelector.ThrowArgumentNullExceptionIfNull(nameof(groupObservableSelector)); - - return source.GroupOnObservable(AdaptSelector>(groupObservableSelector)); - } - - /// - /// Groups the source using the property specified by the property selector. Groups are re-applied when the property value changed. - /// When there are likely to be a large number of group property changes specify a throttle to improve performance. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group by a property value. - /// The property selector used to group the items. - /// An optional a time span that indicates the throttle to wait for property change events. - /// An optional for scheduling work. - /// An observable which will emit immutable group change sets. - public static IObservable> GroupOnProperty(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - return new GroupOnProperty(source, propertySelector, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Groups the source using the property specified by the property selector. Each update produces immutable grouping. Groups are re-applied when the property value changed. - /// When there are likely to be a large number of group property changes specify a throttle to improve performance. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group by a property value with immutable snapshots. - /// The property selector used to group the items. - /// An optional a time span that indicates the throttle to wait for property change events. - /// An optional for scheduling work. - /// An observable which will emit immutable group change sets. - public static IObservable> GroupOnPropertyWithImmutableState(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - return new GroupOnPropertyWithImmutableState(source, propertySelector, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Groups items by , emitting immutable group snapshots instead of mutable sub-caches. - /// Each group change contains a frozen copy of the group's state at that point in time. - /// - /// The type of the object. - /// The type of the key. - /// The type of the group key. - /// The source to group with immutable snapshots. - /// A that extracts the group key from each item. - /// An that optional signal to force re-evaluation of all items against the group selector. - /// An observable that emits immutable group changesets. - /// - /// - /// Behaves identically to - /// in terms of how items are assigned to groups, but each group emission is an immutable snapshot. - /// This makes it safe for parallel processing and eliminates race conditions on group state. - /// The tradeoff is higher memory usage, since each change produces a new snapshot of the affected group. - /// - /// - /// EventBehavior - /// AddItem added to its group. An immutable snapshot of the group is emitted. - /// UpdateIf group key unchanged, group snapshot re-emitted. If changed, item moves between groups; both affected groups emit new snapshots. - /// RemoveItem removed from group. Updated snapshot emitted. Empty groups are removed. - /// RefreshGroup key re-evaluated. If changed, item moves; affected group snapshots emitted. - /// - /// - /// - /// - public static IObservable> GroupWithImmutableState(this IObservable> source, Func groupSelectorKey, IObservable? regrouper = null) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); - - return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); - } - - /// - /// Ignores updates when the update is the same reference. - /// - /// The object of the change set. - /// The key of the change set. - /// The source to suppress same-reference updates in. - /// An observable which emits change sets and ignores equal value changes. - public static IObservable> IgnoreSameReferenceUpdate(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.IgnoreUpdateWhen((c, p) => ReferenceEquals(c, p)); - - /// - /// Ignores the update when the condition is met. - /// The first parameter in the ignore function is the current value and the second parameter is the previous value. - /// - /// The type of the object. - /// The type of the key. - /// The source to selectively suppress updates in. - /// The ignore function (current,previous)=>{ return true to ignore }. - /// An observable which emits change sets and ignores updates equal to the lambda. - public static IObservable> IgnoreUpdateWhen(this IObservable> source, Func ignoreFunction) - where TObject : notnull - where TKey : notnull => source.Select( - updates => - { - var result = updates.Where( - u => - { - if (u.Reason != ChangeReason.Update) - { - return true; - } - - return !ignoreFunction(u.Current, u.Previous.Value); - }); - return new ChangeSet(result); - }).NotEmpty(); - - /// - /// Only includes the update when the condition is met. - /// The first parameter in the ignore function is the current value and the second parameter is the previous value. - /// - /// The type of the object. - /// The type of the key. - /// The source to selectively include updates in. - /// The include function (current,previous)=>{ return true to include }. - /// An observable which emits change sets and ignores updates equal to the lambda. - public static IObservable> IncludeUpdateWhen(this IObservable> source, Func includeFunction) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - includeFunction.ThrowArgumentNullExceptionIfNull(nameof(includeFunction)); - - return source.Select( - changes => - { - var result = changes.Where(change => change.Reason != ChangeReason.Update || includeFunction(change.Current, change.Previous.Value)); - return new ChangeSet(result); - }).NotEmpty(); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the left and right values into a destination object. The composite key is not provided in this overload. - /// Overload that omits the composite key from the result selector. Delegates to . - public static IObservable> InnerJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.InnerJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Joins two changeset streams, producing a result only for keys that exist on both sides simultaneously. - /// When either side loses its value for a key, the joined result is removed. Equivalent to SQL INNER JOIN. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the composite key, left value, and right value into a destination object. Example: ((leftKey, rightKey), left, right) => new Result(leftKey, rightKey, left, right). - /// An observable changeset keyed by a composite (TLeftKey, TRightKey) tuple. - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddIf a matching right value exists, invokes and emits an Add. If no right match, no emission. - /// UpdateIf a matching right exists, re-invokes the selector and emits an Update. - /// RemoveRemoves all joined results involving the removed left key. - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddIf a matching left value exists, invokes the selector and emits an Add. - /// UpdateIf a matching left exists, re-invokes the selector and emits an Update. - /// RemoveRemoves the joined result for this right key (if it was downstream). - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// The output is keyed by a (TLeftKey, TRightKey) composite tuple, since a single left item may match multiple right items. - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> InnerJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func<(TLeftKey leftKey, TRightKey rightKey), TLeft, TRight, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new InnerJoin(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the left value and the right group into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> InnerJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.InnerJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Groups right-side items by their mapped key, then inner-joins each group to the left source. - /// A result is produced only when a left item and at least one right item share the same key. - /// Equivalent to SQL INNER JOIN with the right side grouped. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the key, left value, and right group into a destination object. Example: (key, left, group) => new Result(key, left, group). - /// An observable changeset keyed by . - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddIf a non-empty right group exists for this key, invokes and emits an Add. Otherwise no emission. - /// UpdateIf a right group exists, re-invokes the selector and emits an Update. - /// RemoveRemoves the joined result (if it was downstream). - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddUpdates the right group. If a matching left exists and the group was previously empty, emits an Add. If already joined, emits an Update. - /// UpdateUpdates the right group and re-invokes the selector if a matching left exists. - /// RemoveUpdates the right group. If the group becomes empty, removes the joined result. - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> InnerJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new InnerJoinMany(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// Calls Evaluate() on items that implement when a Refresh change arrives. - /// Other change reasons are forwarded without invoking Evaluate. - /// - /// The type of the object. - /// The type of the key. - /// The source to trigger re-evaluation on. - /// An observable that emits the same changesets as , unchanged. - /// - /// - /// EventBehavior - /// AddForwarded unchanged. - /// UpdateForwarded unchanged. - /// RemoveForwarded unchanged. - /// RefreshCalls Evaluate() on the item, then forwards the change. - /// - /// - public static IObservable> InvokeEvaluate(this IObservable> source) - where TObject : IEvaluateAware - where TKey : notnull => source.Do(changes => changes.Where(u => u.Reason == ChangeReason.Refresh).ForEach(u => u.Current.Evaluate())); - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the left value and the optional right into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> LeftJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.LeftJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Joins two changeset streams, producing a result for every left-side key. The right side is - /// because a matching right item may or may not exist. All left items - /// appear in the output regardless. Equivalent to SQL LEFT OUTER JOIN. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the key, left value, and optional right into a destination object. Example: (key, left, right) => new Result(key, left, right). - /// An observable changeset keyed by . - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddAlways emits. Invokes with the left value and matching right (or ). - /// UpdateRe-invokes the selector with the new left value and current right (if any). - /// RemoveRemoves the joined result. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddIf a matching left exists, re-invokes the selector (right transitions from None to Some) and emits an Update. - /// UpdateIf a matching left exists, re-invokes the selector with the new right value. - /// RemoveIf a matching left exists, re-invokes the selector (right transitions from Some to None) and emits an Update. - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> LeftJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new LeftJoin(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the left value and the right group into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> LeftJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.LeftJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Groups right-side items by their mapped key, then left-joins each group to the left source. - /// A result is produced for every left-side key. The right group may be empty if no right items match. - /// Equivalent to SQL LEFT OUTER JOIN with the right side grouped. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the key, left value, and right group into a destination object. Example: (key, left, group) => new Result(key, left, group). - /// An observable changeset keyed by . - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddAlways emits. Invokes with the left value and the current right group (which may be empty). - /// UpdateRe-invokes the selector with the new left value and current right group. - /// RemoveRemoves the joined result. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddUpdates the right group. If a matching left exists, re-invokes the selector and emits an Update. - /// UpdateUpdates the right group and re-invokes the selector if a matching left exists. - /// RemoveUpdates the right group. If a matching left exists, re-invokes the selector (group may now be empty). - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> LeftJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new LeftJoinMany(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// Applies a FIFO size limit to the changeset stream. When the number of items exceeds , - /// the oldest items are evicted and emitted as Remove changes. - /// - /// The type of the object. - /// The type of the key. - /// The source to apply size limits to. - /// The maximum number of items allowed. Must be greater than zero. - /// An observable changeset stream with size-limited contents. - /// - /// - /// EventBehavior - /// AddForwarded. If the cache exceeds the size limit, the oldest items are emitted as Remove changes. - /// UpdateForwarded unchanged. - /// RemoveForwarded unchanged. - /// RefreshForwarded unchanged. - /// - /// - /// is . - /// is zero or negative. - public static IObservable> LimitSizeTo(this IObservable> source, int size) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (size <= 0) - { - throw new ArgumentException("Size limit must be greater than zero"); - } - - return new SizeExpirer(source, size).Run(); - } - - /// - /// Operates directly on a , removing the oldest items when the cache - /// exceeds . Returns an observable of the evicted key-value pairs (not a changeset stream). - /// - /// The type of the object. - /// The type of the key. - /// The to operate on. - /// The maximum number of items allowed. Must be greater than zero. - /// An optional for observing changes. Defaults to . - /// An observable that emits batches of evicted key-value pairs whenever the cache exceeds the size limit. - /// is . - /// is zero or negative. - public static IObservable>> LimitSizeTo(this ISourceCache source, int sizeLimit, IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (sizeLimit <= 0) - { - throw new ArgumentException("Size limit must be greater than zero", nameof(sizeLimit)); - } - - return Observable.Create>>( - observer => - { - long orderItemWasAdded = -1; - var sizeLimiter = new SizeLimiter(sizeLimit); - - return source.Connect().Finally(observer.OnCompleted).ObserveOn(scheduler ?? GlobalConfig.DefaultScheduler).Transform((t, v) => new ExpirableItem(t, v, DateTime.Now, Interlocked.Increment(ref orderItemWasAdded))).Select(sizeLimiter.CloneAndReturnExpiredOnly).Where(expired => expired.Length != 0).Subscribe( - toRemove => - { - try - { - source.Remove(toRemove.Select(kv => kv.Key)); - observer.OnNext(toRemove); - } - catch (Exception ex) - { - observer.OnError(ex); - } - }); - }); - } - - /// - /// Subscribes to a child observable for each item in the source cache changeset stream and merges all child - /// emissions into a single . When an item is added, - /// creates its child subscription. When updated, the previous child subscription is disposed and a new one is created. - /// When removed, its child subscription is disposed. Refresh changes have no effect on subscriptions. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of values emitted by child observables. - /// The source whose items each produce an observable. - /// A factory function that produces a child observable for each source item. - /// An observable that emits values from all active child observables, interleaved by arrival order. - /// - /// - /// This operator does not produce changesets. It produces a flat stream of - /// values, similar to Rx SelectMany but lifecycle-aware: child subscriptions track items entering and - /// leaving the source cache. - /// - /// - /// EventBehavior - /// AddCalls to create a child observable and subscribes to it. Emissions from the child flow into the merged output. - /// UpdateDisposes the previous child subscription and creates a new one for the updated item. - /// RemoveDisposes the child subscription for the removed item. - /// RefreshNo effect on subscriptions. The child observable continues unchanged. - /// OnErrorErrors from child observables are silently swallowed (the child is unsubscribed). Errors from the source changeset stream terminate the merged output. - /// - /// Worth noting: The output is a plain , not a changeset stream. If you need merged changesets, use instead. - /// - /// or is null. - /// - /// - /// - /// - public static IObservable MergeMany(this IObservable> source, Func> observableSelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeMany(source, observableSelector).Run(); - } - - /// - /// The source whose items each produce an observable. - /// A factory function that receives both the item and its key, and returns a child observable. - public static IObservable MergeMany(this IObservable> source, Func> observableSelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeMany(source, observableSelector).Run(); - } - - /// - /// Merges multiple changeset streams that arrive dynamically into a single unified changeset stream. - /// Each inner stream emitted by the outer observable is subscribed and its changes forwarded downstream. - /// When multiple sources provide the same key, the first source to add it retains priority unless a - /// comparer-based overload is used. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// An that emits changeset streams. Each inner stream is subscribed as it appears. - /// A unified changeset stream containing changes from all active source streams. - /// - /// - /// Each inner changeset stream is independently tracked in its own cache. When multiple sources provide the same key, - /// this overload uses first-in-wins semantics: the value from whichever source added the key first is - /// the one published downstream. To control which value wins for duplicate keys, use an overload that - /// accepts an , which selects the lowest-ordered value across all sources. - /// An can be provided separately to suppress no-op updates when - /// the new value equals the currently published value for a key. - /// - /// - /// Overload families: MergeChangeSets has 16 overloads organized along three axes: - /// (1) Source type: dynamic (IObservable<IObservable<IChangeSet>>, sources arrive at runtime), - /// pair (source + other, exactly two streams), or static (, all sources known up front). - /// (2) Conflict resolution: none (first-in-wins), (lowest-ordered wins), - /// (suppresses duplicate updates), or both. - /// (3) Completion: static overloads accept a completable flag; when , the output never completes - /// even after all sources finish (useful for "live" merge scenarios). - /// - /// - /// EventBehavior - /// AddIf no source has previously provided this key, an Add is emitted downstream. If another source already holds this key, the new value is tracked internally but not emitted (first-in-wins). With a comparer, the lowest-ordered value across all sources is selected and published instead. - /// UpdateIf the updating source currently owns the downstream value for this key, an Update is emitted. If a comparer is provided and the update causes a different source's value to become the best candidate, an Update is emitted with that other source's value. - /// RemoveIf the removed value was the one published downstream, the operator scans all remaining sources for the same key. If another source still holds that key, an Update is emitted with the replacement value (selected by comparer if provided, otherwise the next available). If no other source holds the key, a Remove is emitted. - /// RefreshIf the refreshed item matches the currently published value, the Refresh is forwarded. With a comparer, all sources are re-evaluated first; if a different value now wins, an Update is emitted instead of the Refresh. - /// OnCompletedFor dynamic overloads, the output completes when the outer observable completes and all subscribed inner observables have also completed. For static overloads, completion depends on the completable parameter (default ). - /// - /// - /// Worth noting: When a source removes a key that was published downstream, the fallback to another - /// source's value is emitted as an Update (not an Add). This can be surprising if you expect - /// a Remove followed by an Add. Also, errors from any single inner source terminate the entire merged - /// stream, so consider error handling within individual sources if isolation is needed. - /// - /// - /// is . - /// - /// - /// - public static IObservable> MergeChangeSets(this IObservable>> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new MergeChangeSets(source, equalityComparer: null, comparer: null).Run(); - } - - /// - /// Merges dynamic cache changeset streams into a single output, using a comparer to resolve key conflicts. - /// When multiple sources provide the same key, the item ordering lowest according to - /// is published downstream. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// An that emits changeset streams. Each inner stream is subscribed as it appears. - /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. - /// A unified changeset stream containing changes from all active source streams. - /// or is null. - public static IObservable> MergeChangeSets(this IObservable>> source, IComparer comparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new MergeChangeSets(source, equalityComparer: null, comparer).Run(); - } - - /// - /// Merges dynamic cache changeset streams into a single output, using an equality comparer to suppress - /// redundant updates. When an incoming value for a key is equal (per ) - /// to the currently published value, the update is suppressed. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// An that emits changeset streams. Each inner stream is subscribed as it appears. - /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. - /// A unified changeset stream containing changes from all active source streams. - /// or is null. - public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer equalityComparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - - return new MergeChangeSets(source, equalityComparer, comparer: null).Run(); - } - - /// - /// Merges dynamic cache changeset streams into a single output, using both a comparer for key conflict resolution - /// and an equality comparer to suppress redundant updates. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// An that emits changeset streams. Each inner stream is subscribed as it appears. - /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. - /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. - /// A unified changeset stream containing changes from all active source streams. - /// , , or is null. - public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer equalityComparer, IComparer comparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new MergeChangeSets(source, equalityComparer, comparer).Run(); - } - - /// - /// Convenience overload that merges exactly two cache changeset streams into a single output. - /// Uses first-in-wins semantics for key conflicts. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The second to merge with . - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when both streams complete. If , the output never completes. - /// A unified changeset stream containing changes from both sources. - /// or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - other.ThrowArgumentNullExceptionIfNull(nameof(other)); - - return new[] { source, other }.MergeChangeSets(scheduler, completable); - } - - /// - /// Convenience overload that merges exactly two cache changeset streams, using a comparer for key conflict resolution. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The second to merge with . - /// An that comparer to determine which value wins when both sources provide the same key. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when both streams complete. If , the output never completes. - /// A unified changeset stream containing changes from both sources. - /// , , or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IComparer comparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - other.ThrowArgumentNullExceptionIfNull(nameof(other)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new[] { source, other }.MergeChangeSets(comparer, scheduler, completable); - } - - /// - /// Convenience overload that merges exactly two cache changeset streams, using an equality comparer to suppress redundant updates. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The second to merge with . - /// An that equality comparer to detect duplicate values for the same key. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when both streams complete. If , the output never completes. - /// A unified changeset stream containing changes from both sources. - /// , , or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer equalityComparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - other.ThrowArgumentNullExceptionIfNull(nameof(other)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - - return new[] { source, other }.MergeChangeSets(equalityComparer, scheduler, completable); - } - - /// - /// Convenience overload that merges exactly two cache changeset streams, using both a comparer and an equality comparer. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The second to merge with . - /// An that equality comparer to detect duplicate values for the same key. - /// An that comparer to determine which value wins when both sources provide the same key. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when both streams complete. If , the output never completes. - /// A unified changeset stream containing changes from both sources. - /// , , , or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer equalityComparer, IComparer comparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - other.ThrowArgumentNullExceptionIfNull(nameof(other)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new[] { source, other }.MergeChangeSets(equalityComparer, comparer, scheduler, completable); - } - - /// - /// Merges with additional changeset streams into a single output. - /// Uses first-in-wins semantics for key conflicts. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The additional streams to merge with . - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all streams complete. If , the output never completes. - /// A unified changeset stream containing changes from all sources. - /// or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.EnumerateOne().Concat(others).MergeChangeSets(scheduler, completable); - } - - /// - /// Merges with additional changeset streams, using a comparer for key conflict resolution. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The additional streams to merge with . - /// An that comparer to determine which value wins when multiple sources provide the same key. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all streams complete. If , the output never completes. - /// A unified changeset stream containing changes from all sources. - /// , , or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IComparer comparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.EnumerateOne().Concat(others).MergeChangeSets(comparer, scheduler, completable); - } - - /// - /// Merges with additional changeset streams, using an equality comparer to suppress redundant updates. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The additional streams to merge with . - /// An that equality comparer to detect duplicate values for the same key. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all streams complete. If , the output never completes. - /// A unified changeset stream containing changes from all sources. - /// , , or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer equalityComparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - - return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, scheduler, completable); - } - - /// - /// Merges with additional changeset streams, using both a comparer and an equality comparer. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// The additional streams to merge with . - /// An that equality comparer to detect duplicate values for the same key. - /// An that comparer to determine which value wins when multiple sources provide the same key. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all streams complete. If , the output never completes. - /// A unified changeset stream containing changes from all sources. - /// , , , or is null. - public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer equalityComparer, IComparer comparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, comparer, scheduler, completable); - } - - /// - /// Merges a fixed collection of cache changeset streams into a single unified output. All source streams are - /// subscribed when the output observable is subscribed to. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all source streams have completed. If , the output never completes. - /// A unified changeset stream containing changes from all source streams. - /// - /// - /// When multiple sources provide items with the same key, this overload uses first-in-wins semantics: - /// the first source to provide a key retains priority. Removing that source's item allows the next - /// available value for that key (if any) to surface. To control which value wins, use an overload - /// that accepts an . - /// - /// - /// An error from any source terminates the entire merged output. - /// - /// - /// is null. - public static IObservable> MergeChangeSets(this IEnumerable>> source, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new MergeChangeSets(source, equalityComparer: null, comparer: null, completable, scheduler).Run(); - } - - /// - /// Merges a fixed collection of cache changeset streams into a single output, using a comparer for key conflict - /// resolution. When multiple sources provide the same key, the item ordering lowest according to - /// is published downstream. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all source streams have completed. If , the output never completes. - /// A unified changeset stream containing changes from all source streams. - /// or is null. - public static IObservable> MergeChangeSets(this IEnumerable>> source, IComparer comparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new MergeChangeSets(source, equalityComparer: null, comparer, completable, scheduler).Run(); - } - - /// - /// Merges a fixed collection of cache changeset streams into a single output, using an equality comparer to - /// suppress redundant updates. When an incoming value for a key is equal (per ) - /// to the currently published value, the update is suppressed. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all source streams have completed. If , the output never completes. - /// A unified changeset stream containing changes from all source streams. - /// or is null. - public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer equalityComparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - - return new MergeChangeSets(source, equalityComparer, comparer: null, completable, scheduler).Run(); - } - - /// - /// Merges a fixed collection of cache changeset streams into a single output, using both a comparer for key - /// conflict resolution and an equality comparer to suppress redundant updates. - /// - /// The type of items in the changesets. - /// The type of the key identifying items. - /// The source to merge. - /// An that equality comparer to detect duplicate values for the same key, suppressing no-op updates. - /// An that comparer to determine which value wins when multiple sources provide the same key. The lowest-ordered value is published. - /// An optional used when subscribing to the source streams. - /// If (default), the output completes when all source streams have completed. If , the output never completes. - /// A unified changeset stream containing changes from all source streams. - /// , , or is null. - public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer equalityComparer, IComparer comparer, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - equalityComparer.ThrowArgumentNullExceptionIfNull(nameof(equalityComparer)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new MergeChangeSets(source, equalityComparer, comparer, completable, scheduler).Run(); - } - - /// - /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child changes - /// into a single flattened output. This overload requires a comparer for resolving destination key conflicts. - /// The selector receives only the item, not its key. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child cache changeset stream. - /// An that comparer to resolve key conflicts when multiple child streams provide items with the same destination key. The lowest-ordered item wins. - /// A merged changeset stream containing items from all active child streams. - /// or is null. - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return source.MergeManyChangeSets((t, _) => observableSelector(t), comparer); - } - - /// - /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child changes - /// into a single flattened output. This overload requires a comparer for resolving destination key conflicts. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and its key, and returns a child cache changeset stream. - /// An that comparer to resolve key conflicts when multiple child streams provide items with the same destination key. The lowest-ordered item wins. - /// A merged changeset stream containing items from all active child streams. - /// , , or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.MergeManyChangeSets(observableSelector, equalityComparer: null, comparer: comparer); - } - - /// - /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child changes - /// into a single flattened output. The selector receives only the item, not its key. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child cache changeset stream. - /// An that optional equality comparer to suppress updates when the incoming child value equals the current value for a destination key. - /// An that optional comparer to resolve key conflicts when multiple child streams provide items with the same destination key. The lowest-ordered item wins. - /// A merged changeset stream containing items from all active child streams. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return source.MergeManyChangeSets((t, _) => observableSelector(t), equalityComparer, comparer); - } - - /// - /// For each item in the source cache, subscribes to a child changeset stream and merges all child - /// changes into a single flattened output stream. Child subscriptions track the parent item lifecycle: - /// created on Add, replaced on Update, disposed on Remove. - /// - /// The type of items in the source (parent) cache. - /// The type of the key identifying parent items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a parent item and its key, and returns a child cache changeset stream. Called once per parent Add/Update. - /// An that optional equality comparer to suppress no-op child updates. When a child key's new value equals the current value per this comparer, the update is not emitted. - /// An that optional comparer to resolve child key conflicts when multiple parents contribute children with the same destination key. The lowest-ordered child value wins. Without a comparer, the first parent to provide a key retains priority. - /// A merged changeset stream containing all child items from all active parent subscriptions. - /// - /// - /// This is the changeset-aware counterpart to . - /// Where MergeMany produces a flat IObservable<T>, MergeManyChangeSets produces an IObservable<IChangeSet> - /// that tracks the full lifecycle of child items, including key conflict resolution across parents. - /// - /// - /// Parent-side change handling (source changeset events): - /// - /// - /// EventBehavior - /// AddCalls with the new parent item to obtain a child changeset stream, then subscribes. As the child stream emits changesets, those child items are merged into the output. The downstream observer sees Add changes for each new child item. - /// UpdateDisposes the previous parent's child subscription (removing all of its contributed child items from the output as Remove changes), then creates a new child subscription for the updated parent. The new child's items appear as Add changes. - /// RemoveDisposes the parent's child subscription. All child items contributed by that parent are emitted as Remove changes in the output. If another parent also provides a child with the same destination key, that parent's value is promoted as an Update (not an Add). - /// RefreshNo effect on the child subscription. The parent's child stream continues unchanged. - /// - /// - /// Child-side change handling (changes arriving from child changeset streams): - /// - /// - /// EventBehavior - /// AddIf the destination key is new, an Add is emitted. If another parent already contributed a child with the same key, the conflict is resolved by (lowest wins) or first-in-wins if no comparer. The losing value is tracked internally but not emitted. - /// UpdateIf this parent currently owns the destination key downstream, an Update is emitted. With a comparer, all parents are re-evaluated for that key; a different parent's value may win, producing an Update to that value instead. - /// RemoveIf this parent's value was the one published downstream for that destination key, the operator scans other parents for the same key. If found, an Update is emitted with the replacement. If not, a Remove is emitted. - /// RefreshIf the child item is the one currently published downstream, the Refresh is forwarded. With a comparer, all parents are re-evaluated first; if a different value now wins, an Update is emitted instead. - /// - /// - /// Error and completion: - /// - /// - /// EventBehavior - /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. - /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. - /// - /// - /// Worth noting: When multiple parents contribute children with the same destination key, only one value is published - /// downstream at a time. The controls which value wins; without it, the first parent to add the key - /// retains priority. Removing a parent that owned a contested key causes the next-best value (per comparer or next available) - /// to surface as an Update, not an Add. The independently controls whether a child - /// Update for an already-published key is suppressed when the new value equals the old. - /// - /// - /// or is . - /// - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); - } - - /// - /// Source-priority variant of MergeManyChangeSets with a required . - /// Uses to resolve destination key conflicts by source priority. - /// The selector receives only the item, not its key. - /// Source priorities are always re-evaluated on Refresh (default behavior). - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. Lower-ordered source wins. - /// An that fallback comparer to resolve destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IComparer childComparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, DefaultResortOnSourceRefresh, equalityComparer: null, childComparer); - } - - /// - /// Source-priority variant of MergeManyChangeSets with a required . - /// Uses to resolve destination key conflicts by source priority. - /// Source priorities are always re-evaluated on Refresh (default behavior). - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and its key, and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. Lower-ordered source wins. - /// An that fallback comparer to resolve destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IComparer childComparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull => source.MergeManyChangeSets(observableSelector, sourceComparer, DefaultResortOnSourceRefresh, equalityComparer: null, childComparer); - - /// - /// Source-priority variant of MergeManyChangeSets with a required and - /// explicit control. The selector receives only the item. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. - /// If , a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. - /// An that fallback comparer to resolve destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IComparer childComparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, resortOnSourceRefresh, equalityComparer: null, childComparer); - } - - /// - /// Source-priority variant of MergeManyChangeSets with a required and - /// explicit control. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and its key, and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. - /// If , a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. - /// An that fallback comparer to resolve destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IComparer childComparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull => source.MergeManyChangeSets(observableSelector, sourceComparer, resortOnSourceRefresh, equalityComparer: null, childComparer); - - /// - /// Source-priority variant of MergeManyChangeSets. Uses to resolve - /// destination key conflicts. The selector receives only the item, not its key. - /// Source priorities are always re-evaluated on Refresh (default behavior). - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. - /// An that optional equality comparer to suppress updates when the incoming child value equals the current value. - /// An that optional fallback comparer for destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, DefaultResortOnSourceRefresh, equalityComparer, childComparer); - } - - /// - /// Source-priority variant of MergeManyChangeSets. Uses to resolve - /// destination key conflicts. Source priorities are always re-evaluated on Refresh (default behavior). - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and its key, and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. - /// An that optional equality comparer to suppress updates when the incoming child value equals the current value. - /// An that optional fallback comparer for destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull => source.MergeManyChangeSets(observableSelector, sourceComparer, DefaultResortOnSourceRefresh, equalityComparer, childComparer); - - /// - /// Source-priority variant of MergeManyChangeSets with full control over all conflict resolution parameters. - /// The selector receives only the item, not its key. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. - /// If , a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. - /// An that optional equality comparer to suppress updates when the incoming child value equals the current value. - /// An that optional fallback comparer for destination key conflicts when source items compare equal. - /// A merged changeset stream with conflicts resolved by source priority. - /// or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return source.MergeManyChangeSets((t, _) => observableSelector(t), sourceComparer, resortOnSourceRefresh, equalityComparer, childComparer); - } - - /// - /// For each item in the source cache, subscribes to a child cache changeset stream and merges all child - /// changes into a single flattened output. When multiple source items produce children with the same destination key, - /// determines which source has priority (the source ordering lower wins). - /// If sources compare equal, (if provided) breaks the tie. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child changeset streams. - /// The type of the key identifying child items. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and its key, and returns a child cache changeset stream. - /// An that comparer to prioritize between source items when their children produce the same destination key. Lower-ordered source wins. - /// If (default), a Refresh in the source stream re-evaluates source priorities. If , Refresh events are ignored for priority recalculation. - /// An that optional equality comparer to suppress updates when the incoming child value equals the current value for a destination key. - /// An that optional fallback comparer to resolve destination key conflicts when source items compare equal. - /// A merged changeset stream containing items from all active child streams, with conflicts resolved by source priority. - /// - /// - /// The provides a layer of conflict resolution above the child values themselves. - /// This is useful when source items represent priority tiers (e.g., user settings overriding defaults). - /// - /// - /// Errors from child streams propagate to the output. An error from the source or any child terminates the merged output. - /// The output completes when the source completes and all active child streams have also completed. - /// - /// - /// , , or is null. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer sourceComparer, bool resortOnSourceRefresh, IEqualityComparer? equalityComparer = null, IComparer? childComparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - sourceComparer.ThrowArgumentNullExceptionIfNull(nameof(sourceComparer)); - - return new MergeManyCacheChangeSetsSourceCompare(source, observableSelector, sourceComparer, equalityComparer, childComparer, resortOnSourceRefresh).Run(); - } - - /// - /// For each item in the source cache, subscribes to a child list changeset stream produced by - /// and merges all child changes into a single flattened list changeset output. - /// Child subscriptions follow the source item lifecycle: created on Add, replaced on Update, disposed on Remove. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child list changeset streams. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and its key, and returns a child list changeset stream. - /// An that optional equality comparer to detect duplicate items in the merged list output. - /// A merged list changeset stream containing items from all active child streams. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeManyListChangeSets(source, observableSelector, equalityComparer).Run(); - } - - /// - /// For each item in the source cache, subscribes to a child list changeset stream and merges all child changes - /// into a single flattened list changeset output. The selector receives only the item, not its key. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of items in the child list changeset streams. - /// The source whose items each produce a child changeset stream. - /// A factory function that receives a source item and returns a child list changeset stream. - /// An that optional equality comparer to detect duplicate items in the merged list output. - /// A merged list changeset stream containing items from all active child streams. - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - { - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - return source.MergeManyChangeSets((obj, _) => observableSelector(obj), equalityComparer); - } - - /// - /// Like , - /// but wraps each emitted value as an , pairing the source item - /// with the value it produced. This lets you identify which source item is responsible for each emission. - /// - /// The type of items in the source cache. - /// The type of the key identifying source cache items. - /// The type of values emitted by child observables. - /// The source whose items each produce an observable. - /// A factory function that produces a child observable for each source item. - /// An observable of pairing each emission with its source item. - /// or is null. - public static IObservable> MergeManyItems(this IObservable> source, Func> observableSelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeManyItems(source, observableSelector).Run(); - } - - /// - /// The source whose items each produce an observable. - /// A factory function that receives both the item and its key, and returns a child observable. - public static IObservable> MergeManyItems(this IObservable> source, Func> observableSelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeManyItems(source, observableSelector).Run(); - } - - /// - /// Monitors the source observable and emits values: Pending initially, - /// Loaded when the first value arrives, Errored on error, and Completed on completion. - /// This is not a changeset operator. - /// - /// The type of the source observable. - /// The source to monitor for connection status. - /// An observable that emits values reflecting the source's lifecycle. - /// is . - /// - public static IObservable MonitorStatus(this IObservable source) => new StatusMonitor(source).Run(); - - /// - /// Filters out empty changesets from the stream. A thin wrapper around Where(changes => changes.Count != 0). - /// - /// The type of the object. - /// The type of the key. - /// The source to suppress empty changesets. - /// An observable that emits only non-empty changesets. - /// is . - /// - public static IObservable> NotEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Where(changes => changes.Count != 0); - } - - /// - /// Filters and casts items in the changeset to . Items that are not of type - /// are excluded. Combines filter and transform in one step without an intermediate cache. - /// - /// The type of the objects in the source changeset. - /// The type of the key. - /// The destination type to filter and cast to. - /// The source to filter by type. - /// If , changesets that become empty after filtering are suppressed. - /// An observable changeset of items. - /// - /// - /// EventBehavior - /// AddIf the item is , cast and emit as Add. Otherwise dropped. - /// UpdateRe-evaluated. If the new item is , emit accordingly. If the old item was downstream but the new one is not, emit Remove. - /// RemoveIf the item was downstream, emit Remove. - /// RefreshIf the item is downstream, forwarded as Refresh. - /// - /// - /// is . - public static IObservable> OfType(this IObservable> source, bool suppressEmptyChangeSets = true) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new OfType(source, suppressEmptyChangeSets).Run(); - } - - /// - /// Callback for each item as and when it is being added to the stream. - /// - /// The type of the object. - /// The type of the key. - /// The source to observe item additions in. - /// The callback invoked for each added item. Receives the new item and its key. - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddInvokes with the item and key. - /// UpdateIgnored. - /// RemoveIgnored. - /// RefreshIgnored. - /// - /// - /// - /// Exceptions thrown in propagate as OnError. No try-catch is applied. - /// - /// - /// or is . - /// - /// - /// - /// - public static IObservable> OnItemAdded(this IObservable> source, Action addAction) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - addAction.ThrowArgumentNullExceptionIfNull(nameof(addAction)); - - return source.OnChangeAction(ChangeReason.Add, addAction); - } - - /// - /// The source to observe item additions in. - /// The callback invoked for each added item. Receives only the item (no key). - /// Overload that omits the key from the callback. Delegates to . - public static IObservable> OnItemAdded(this IObservable> source, Action addAction) - where TObject : notnull - where TKey : notnull - => source.OnItemAdded((obj, _) => addAction(obj)); - - /// - /// Callback for each item as and when it is being refreshed in the stream. - /// - /// The type of the object. - /// The type of the key. - /// The source to observe item refresh events in. - /// The callback invoked for each refreshed item. Receives the item and its key. - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddIgnored. - /// UpdateIgnored. - /// RemoveIgnored. - /// RefreshInvokes with the item and key. - /// - /// - /// - /// Exceptions thrown in propagate as OnError. No try-catch is applied. - /// - /// - /// or is . - /// - /// - public static IObservable> OnItemRefreshed(this IObservable> source, Action refreshAction) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - refreshAction.ThrowArgumentNullExceptionIfNull(nameof(refreshAction)); - - return source.OnChangeAction(ChangeReason.Refresh, refreshAction); - } - - /// - /// The source to observe item refresh events in. - /// The callback invoked for each refreshed item. Receives only the item (no key). - /// Overload that omits the key from the callback. Delegates to . - public static IObservable> OnItemRefreshed(this IObservable> source, Action refreshAction) - where TObject : notnull - where TKey : notnull - => source.OnItemRefreshed((obj, _) => refreshAction(obj)); - - /// - /// Invokes for each item with in the changeset stream. - /// The changeset is forwarded downstream unchanged. - /// - /// The type of the object. - /// The type of the key. - /// The source to observe item removals in. - /// The callback invoked for each removed item. Receives the removed item and its key. - /// - /// When (the default), the callback is also invoked for every item still in the cache - /// when the subscription is disposed. When , only inline Remove changes trigger the callback. - /// - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddIgnored (but tracked internally when is ). - /// UpdateIgnored (cache updated internally when is ). - /// RemoveInvokes with the item and key. - /// RefreshIgnored. - /// - /// - /// - /// Unsubscribe behavior: when is , the operator - /// maintains an internal cache mirroring the stream. On disposal, it iterates all remaining items and - /// invokes for each. This is useful for cleanup logic (e.g. event unsubscription) - /// that must run for items that were never explicitly removed. - /// - /// - /// Exceptions thrown in propagate as OnError during inline removes. - /// During unsubscribe disposal, exceptions are not caught. - /// - /// Worth noting: The action also fires for ALL remaining items when the subscription is disposed (unless invokeOnUnsubscribe is ). The action runs under a lock; avoid calling into other caches from within it. - /// - /// or is . - /// - /// - /// - public static IObservable> OnItemRemoved(this IObservable> source, Action removeAction, bool invokeOnUnsubscribe = true) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - removeAction.ThrowArgumentNullExceptionIfNull(nameof(removeAction)); - - if (invokeOnUnsubscribe) - { - return new OnBeingRemoved(source, removeAction).Run(); - } - - return source.OnChangeAction(ChangeReason.Remove, removeAction); - } - - /// - /// The source to observe item removals in. - /// The callback invoked for each removed item. Receives only the item (no key). - /// When (the default), also invoked for all remaining items on disposal. - /// Overload that omits the key from the callback. Delegates to . - public static IObservable> OnItemRemoved(this IObservable> source, Action removeAction, bool invokeOnUnsubscribe = true) - where TObject : notnull - where TKey : notnull - => source.OnItemRemoved((obj, _) => removeAction(obj), invokeOnUnsubscribe); - - /// - /// Invokes for each item with in the changeset stream. - /// The changeset is forwarded downstream unchanged. - /// - /// The type of the object. - /// The type of the key. - /// The source to observe item updates in. - /// The callback invoked for each updated item. Receives the current value, previous value, and key. - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddIgnored. - /// UpdateInvokes with (current, previous, key). The previous value is always available for Update changes. - /// RemoveIgnored. - /// RefreshIgnored. - /// - /// - /// - /// Exceptions thrown in propagate as OnError. No try-catch is applied. - /// - /// - /// or is . - /// - /// - public static IObservable> OnItemUpdated(this IObservable> source, Action updateAction) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); - - return source.OnChangeAction(static change => change.Reason == ChangeReason.Update, change => updateAction(change.Current, change.Previous.Value, change.Key)); - } - - /// - /// The source to observe item updates in. - /// The callback invoked for each updated item. Receives only the current and previous values (no key). - /// Overload that omits the key from the callback. Delegates to . - public static IObservable> OnItemUpdated(this IObservable> source, Action updateAction) - where TObject : notnull - where TKey : notnull - => source.OnItemUpdated((cur, prev, _) => updateAction(cur, prev)); - - /// - /// Combines multiple changeset streams using logical OR (union). An item appears downstream if it exists in any source. - /// - /// The type of the object. - /// The type of the key. - /// The source to combine. - /// The additional streams to combine with. - /// A changeset stream containing items present in any of the sources. - /// - /// - /// Items are tracked via reference counting across all sources. An item appears downstream as long as - /// at least one source contains it. When the last source holding a key removes it, the item is removed downstream. - /// - /// - /// EventBehavior - /// AddIf this is the first source to provide the key, an Add is emitted. If other sources already have the key, the reference count is incremented but no emission occurs. - /// UpdateIf the item is currently downstream, an Update is emitted. - /// RemoveReference count decremented. If the count reaches zero (no source holds the key), a Remove is emitted. Otherwise no emission. - /// RefreshIf the item is downstream, a Refresh is forwarded. - /// - /// - /// or is . - /// - /// - /// - /// - /// - public static IObservable> Or(this IObservable> source, params IObservable>[] others) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (others is null || others.Length == 0) - { - throw new ArgumentNullException(nameof(others)); - } - - return source.Combine(CombineOperator.Or, others); - } - - /// - /// The of streams to combine. - /// This overload accepts a pre-built collection of sources instead of a params array. - public static IObservable> Or(this ICollection>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Or); - } - - /// - /// Dynamically apply a logical Or operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of streams to combine. - /// An observable which emits change sets. - public static IObservable> Or(this IObservableList>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Or); - } - - /// - /// Dynamically apply a logical Or operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits change sets. - public static IObservable> Or(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Or); - } - - /// - /// Dynamically apply a logical Or operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits change sets. - public static IObservable> Or(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Or); - } - - /// - /// Subscribes to the observable and calls AddOrUpdate on the source cache for each emitted batch of items. - /// - /// The type of the object. - /// The type of the key. - /// The to operate on. - /// The that emits batches of items. - /// An that, when disposed, unsubscribes from . - /// - /// Each emission from is passed to , producing one changeset per emission containing Add or Update events for each item. Errors from propagate and terminate the subscription. Completion ends the subscription; the cache retains all items. - /// - /// or is . - /// - /// - public static IDisposable PopulateFrom(this ISourceCache source, IObservable> observable) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return observable.Subscribe(source.AddOrUpdate); - } - - /// - /// Subscribes to the observable and calls AddOrUpdate on the source cache for each emitted item. - /// - /// The type of the object. - /// The type of the key. - /// The to operate on. - /// The that emits individual items. - /// An that, when disposed, unsubscribes from . - /// or is . - public static IDisposable PopulateFrom(this ISourceCache source, IObservable observable) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return observable.Subscribe(source.AddOrUpdate); - } - - /// - /// Subscribes to the changeset stream and clones each changeset into the destination cache. - /// - /// The type of the object. - /// The type of the key. - /// The source to pipe into a target cache. - /// The that will receive the changes. - /// An that, when disposed, unsubscribes from the source. - /// - /// - /// Each changeset from the source is applied to the destination cache inside an Edit call. - /// - /// - /// EventBehavior - /// AddThe item is added to the destination cache via AddOrUpdate. - /// UpdateThe item is updated in the destination cache via AddOrUpdate. - /// RemoveThe item is removed from the destination cache. - /// RefreshA Refresh is issued on the destination cache for the item. - /// OnErrorThe subscription is terminated. The destination cache is not rolled back. - /// OnCompletedThe subscription ends. The destination cache retains all items. - /// - /// - /// or is . - /// - /// - /// - public static IDisposable PopulateInto(this IObservable> source, ISourceCache destination) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); - } - - /// - /// The source to pipe into a target cache. - /// The that will receive the changes. - /// Overload that targets an . - public static IDisposable PopulateInto(this IObservable> source, IIntermediateCache destination) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); - } - - /// - /// The source to pipe into a target cache. - /// The that will receive the changes. - /// Overload that targets a . - public static IDisposable PopulateInto(this IObservable> source, LockFreeObservableCache destination) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); - } - - /// - /// Projects the current cache state through after each modification. - /// Emits a new value of on every changeset. - /// - /// The type of the object. - /// The type of the key. - /// The type of the destination. - /// The source to project on each change. - /// A function that projects the current snapshot to a result value. - /// An observable that emits a projected value after each changeset. - /// - /// Worth noting: The selector is called on every changeset, which can be chatty. The exposes the full cache state for LINQ-style queries. - /// - /// or is . - /// - /// - /// - public static IObservable QueryWhenChanged(this IObservable> source, Func, TDestination> resultSelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return source.QueryWhenChanged().Select(resultSelector); - } - - /// - /// The latest copy of the cache is exposed for querying i) after each modification to the underlying data ii) upon subscription. - /// - /// The type of the object. - /// The type of the key. - /// The source to project on each change. - /// An observable which emits the query. - /// source. - public static IObservable> QueryWhenChanged(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new QueryWhenChanged(source).Run(); - } - - /// - /// The latest copy of the cache is exposed for querying i) after each modification to the underlying data ii) on subscription. - /// - /// The type of the object. - /// The type of the key. - /// The type of the value. - /// The source to project on each change. - /// A that should the query be triggered for observables on individual items. - /// An observable that emits the query. - /// source. - public static IObservable> QueryWhenChanged(this IObservable> source, Func> itemChangedTrigger) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - itemChangedTrigger.ThrowArgumentNullExceptionIfNull(nameof(itemChangedTrigger)); - - return new QueryWhenChanged(source, itemChangedTrigger).Run(); - } - - /// - /// Cache-aware equivalent of Publish().RefCount(). An internal cache is created on the first subscriber - /// and disposed when the last subscriber unsubscribes. All subscribers share the same upstream subscription. - /// - /// The type of the object. - /// The type of the key. - /// The source to share via reference counting. - /// A ref-counted observable changeset stream. - /// - public static IObservable> RefCount(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new RefCount(source).Run(); - } - - /// - /// Signals downstream operators to re-evaluate the specified item. Produces a changeset with a single Refresh change. - /// - /// The type of the object. - /// The type of the key. - /// The to signal re-evaluation on. - /// The item to refresh. - /// - /// Convenience method that wraps a Refresh inside . A Refresh does not change data in the cache; it signals downstream operators (such as or ) to re-evaluate the item. - /// - /// is . - /// - /// - public static void Refresh(this ISourceCache source, TObject item) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Refresh(item)); - } - - /// - /// Signals downstream operators to re-evaluate the specified items. Produces one changeset with a Refresh for each item. - /// - /// The type of the object. - /// The type of the key. - /// The to signal re-evaluation on. - /// The of items to refresh. - /// is . - public static void Refresh(this ISourceCache source, IEnumerable items) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Refresh(items)); - } - - /// - /// Signals downstream operators to re-evaluate all items in the cache. Produces one changeset with a Refresh for every item. - /// - /// The type of the object. - /// The type of the key. - /// The to signal re-evaluation on. - /// is . - public static void Refresh(this ISourceCache source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Refresh()); - } - - /// - /// Removes the specified item from the cache. Produces a Remove changeset if the item exists, nothing otherwise. - /// - /// The type of the object. - /// The type of the key. - /// The from which to remove items. - /// The item to remove. - /// - /// Convenience method that wraps a single-item removal inside . The key is extracted from the item using the cache's key selector. - /// - /// is . - /// - /// - /// - public static void Remove(this ISourceCache source, TObject item) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Remove(item)); - } - - /// - /// Removes the item with the specified key from the cache. Produces a Remove changeset if the key exists, nothing otherwise. - /// - /// The type of the object. - /// The type of the key. - /// The from which to remove items. - /// The key of the item to remove. - /// is . - public static void Remove(this ISourceCache source, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Remove(key)); - } - - /// - /// Removes the specified items from the cache. Any items not present in the cache are ignored. - /// Produces a Remove changeset for each item that existed. - /// - /// The type of the object. - /// The type of the key. - /// The from which to remove items. - /// The of items to remove. - /// is . - public static void Remove(this ISourceCache source, IEnumerable items) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Remove(items)); - } - - /// - /// Removes the items with the specified keys from the cache. Any keys not present are ignored. - /// Produces a Remove changeset for each key that existed. - /// - /// The type of the object. - /// The type of the key. - /// The from which to remove items. - /// The keys to remove. - /// is . - public static void Remove(this ISourceCache source, IEnumerable keys) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Remove(keys)); - } - - /// - /// The from which to remove items. - /// The key of the item to remove. - /// Overload that targets an . - public static void Remove(this IIntermediateCache source, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Remove(key)); - } - - /// - /// The from which to remove items. - /// The keys to remove. - /// Overload that targets an . - public static void Remove(this IIntermediateCache source, IEnumerable keys) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.Remove(keys)); - } - - /// - /// Strips the key from a cache changeset, converting to - /// (list changeset). All indexed changes are dropped (sorting is not supported). - /// - /// The type of the object. - /// The type of the key. - /// The source to strip keys from, producing an unkeyed list changeset. - /// A list changeset stream without key information. - /// - /// - public static IObservable> RemoveKey(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select( - changes => - { - var enumerator = new RemoveKeyEnumerator(changes); - return new ChangeSet(enumerator); - }); - } - - /// - /// Removes a specific key from the cache. Equivalent to source.Edit(u => u.RemoveKey(key)). - /// - /// The type of the object. - /// The type of the key. - /// The from which to remove a key. - /// The key to remove. - /// is . - public static void RemoveKey(this ISourceCache source, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.RemoveKey(key)); - } - - /// - /// Removes multiple keys from the cache in a single Edit call. Keys not present in the cache are ignored. - /// - /// The type of the object. - /// The type of the key. - /// The from which to remove keys. - /// The keys to remove. - /// is . - public static void RemoveKeys(this ISourceCache source, IEnumerable keys) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - source.Edit(updater => updater.RemoveKeys(keys)); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the optional left and right values into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> RightJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TRight, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.RightJoin(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Joins two changeset streams, producing a result for every right-side key. The left side is - /// because a matching left item may or may not exist. All right items - /// appear in the output regardless. Equivalent to SQL RIGHT OUTER JOIN. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the right key, optional left, and right value into a destination object. Example: (rightKey, left, right) => new Result(rightKey, left, right). - /// An observable changeset keyed by . - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddAlways emits. Invokes with the matching left (or ) and the right value. - /// UpdateRe-invokes the selector with current left (if any) and the new right value. - /// RemoveRemoves the joined result. - /// RefreshForwarded as Refresh on the joined result. - /// - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddIf matching right items exist, re-invokes the selector (left transitions from None to Some) and emits Updates. - /// UpdateIf matching right items exist, re-invokes the selector with the new left value. - /// RemoveIf matching right items exist, re-invokes the selector (left transitions from Some to None) and emits Updates. - /// RefreshIf joined results exist, forwarded as Refresh. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> RightJoin(this IObservable> left, IObservable> right, Func rightKeySelector, Func, TRight, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new RightJoin(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the optional left value and the right group into a destination object. The key is not provided in this overload. - /// Overload that omits the key from the result selector. Delegates to . - public static IObservable> RightJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return left.RightJoinMany(right, rightKeySelector, (_, leftValue, rightValue) => resultSelector(leftValue, rightValue)); - } - - /// - /// Groups right-side items by their mapped key, then right-joins each group to the left source. - /// A result is produced for every key that has at least one right item. The left value is - /// because a matching left item may or may not exist. - /// Equivalent to SQL RIGHT OUTER JOIN with the right side grouped. - /// - /// The item type of the left source. - /// The key type of the left source. - /// The item type of the right source. - /// The key type of the right source. - /// The type produced by . - /// The left to join. - /// The right to join. - /// A that maps each right item to the left key it should join on. - /// A that combines the key, optional left value, and right group into a destination object. Example: (key, left, group) => new Result(key, left, group). - /// An observable changeset keyed by . - /// - /// - /// Right-side change handling: - /// - /// EventBehavior - /// AddUpdates the right group. If the group was previously empty, emits an Add with the current left (if any). Otherwise emits an Update. - /// UpdateUpdates the right group and re-invokes . - /// RemoveUpdates the right group. If the group becomes empty, removes the joined result. - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// - /// Left-side change handling: - /// - /// EventBehavior - /// AddIf a non-empty right group exists, re-invokes the selector (left transitions from None to Some) and emits an Update. - /// UpdateIf a non-empty right group exists, re-invokes the selector with the new left value. - /// RemoveIf a non-empty right group exists, re-invokes the selector (left transitions from Some to None) and emits an Update. - /// RefreshIf a joined result exists, forwarded as Refresh. - /// - /// - /// Both sources are serialized through a shared lock held during downstream delivery. Avoid blocking operations in subscribers. - /// - /// Any argument is . - /// - /// - /// - /// - public static IObservable> RightJoinMany(this IObservable> left, IObservable> right, Func rightKeySelector, Func, IGrouping, TDestination> resultSelector) - where TLeft : notnull - where TLeftKey : notnull - where TRight : notnull - where TRightKey : notnull - where TDestination : notnull - { - left.ThrowArgumentNullExceptionIfNull(nameof(left)); - right.ThrowArgumentNullExceptionIfNull(nameof(right)); - rightKeySelector.ThrowArgumentNullExceptionIfNull(nameof(rightKeySelector)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return new RightJoinMany(left, right, rightKeySelector, resultSelector).Run(); - } - - /// - /// Skips the initial snapshot changeset that Connect() typically emits, then forwards all subsequent changesets. - /// Internally uses DeferUntilLoaded().Skip(1). - /// - /// The type of the object. - /// The type of the key. - /// The source to skip the initial changeset. - /// An observable that skips the first changeset and forwards all others. - /// is . - /// - /// - public static IObservable> SkipInitial(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.DeferUntilLoaded().Skip(1); - } - - /// - /// Obsolete: use SortAndBind instead. Sorts using the specified comparer. - /// - /// The type of the object. - /// The type of the key. - /// The source to sort. - /// The used to determine sort order. - /// A that sort optimisation flags. Specify one or more sort optimisations. - /// The number of updates before the entire list is resorted (rather than inline sort). - /// An observable which emits change sets. - /// - /// source - /// or - /// comparer. - /// - /// - [Obsolete(Constants.SortIsObsolete)] - public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new Sort(source, comparer, sortOptimisations, resetThreshold: resetThreshold).Run(); - } - - /// - /// Obsolete: use SortAndBind instead. Sorts using a dynamic comparer observable. - /// - /// The type of the object. - /// The type of the key. - /// The source to sort. - /// The comparer observable. - /// The sort optimisations. - /// The reset threshold. - /// An observable which emits change sets. - [Obsolete(Constants.SortIsObsolete)] - public static IObservable> Sort(this IObservable> source, IObservable> comparerObservable, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparerObservable.ThrowArgumentNullExceptionIfNull(nameof(comparerObservable)); - - return new Sort(source, null, sortOptimisations, comparerObservable, resetThreshold: resetThreshold).Run(); - } - - /// - /// Obsolete: use SortAndBind instead. Sorts using a dynamic comparer observable with a manual re-sort signal. - /// - /// The type of the object. - /// The type of the key. - /// The source to sort. - /// The comparer observable. - /// An that signals the algorithm to re-sort the entire data set. - /// The sort optimisations. - /// The reset threshold. - /// An observable which emits change sets. - [Obsolete(Constants.SortIsObsolete)] - public static IObservable> Sort(this IObservable> source, IObservable> comparerObservable, IObservable resorter, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparerObservable.ThrowArgumentNullExceptionIfNull(nameof(comparerObservable)); - - return new Sort(source, null, sortOptimisations, comparerObservable, resorter, resetThreshold).Run(); - } - - /// - /// Obsolete: use SortAndBind instead. Sorts using a static comparer with a manual re-sort signal. - /// - /// The type of the object. - /// The type of the key. - /// The source to sort. - /// The used to determine sort order. - /// An that signals the algorithm to re-sort the entire data set. - /// The sort optimisations. - /// The reset threshold. - /// An observable which emits change sets. - [Obsolete(Constants.SortIsObsolete)] - public static IObservable> Sort(this IObservable> source, IComparer comparer, IObservable resorter, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - resorter.ThrowArgumentNullExceptionIfNull(nameof(resorter)); - - return new Sort(source, comparer, sortOptimisations, null, resorter, resetThreshold).Run(); - } - - /// - /// Sorts the changeset stream by the value returned from . Creates a comparer internally - /// and delegates to . - /// Since Sort is obsolete, prefer SortAndBind for new code. - /// - /// The type of the object. - /// The type of the key. - /// The source to sort. - /// A that expression that selects a comparable value from each item. - /// The sort direction. Defaults to ascending. - /// A that sort optimization flags. - /// The number of updates before the entire list is re-sorted (rather than inline sort). - /// An observable that emits sorted changesets. - public static IObservable> SortBy( - this IObservable> source, - Func expression, - SortDirection sortOrder = SortDirection.Ascending, - SortOptimisations sortOptimisations = SortOptimisations.None, - int resetThreshold = DefaultSortResetThreshold) - where TObject : notnull - where TKey : notnull - { - source = source ?? throw new ArgumentNullException(nameof(source)); - expression = expression ?? throw new ArgumentNullException(nameof(expression)); - - return source.Sort( - sortOrder switch - { - SortDirection.Descending => SortExpressionComparer.Descending(expression), - _ => SortExpressionComparer.Ascending(expression), - }, - sortOptimisations, - resetThreshold); - } - - /// - /// Prepends an empty changeset to the source stream, ensuring subscribers always receive an immediate - /// (empty) notification on subscription. Uses Rx's StartWith. - /// - /// The type of the object. - /// The type of the key. - /// The source to prepend an empty changeset to. - /// An observable that emits an empty changeset first, then all source changesets. - /// - public static IObservable> StartWithEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.StartWith(ChangeSet.Empty); - - /// - /// The source to prepend an empty changeset to. - /// An observable that emits an empty sorted changeset first, then all source changesets. - /// Overload for . - public static IObservable> StartWithEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.StartWith(SortedChangeSet.Empty); - - /// - /// The source to prepend an empty changeset to. - /// An observable that emits an empty virtual changeset first, then all source changesets. - /// Overload for . - public static IObservable> StartWithEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.StartWith(VirtualChangeSet.Empty); - - /// - /// The source to prepend an empty changeset to. - /// An observable that emits an empty paged changeset first, then all source changesets. - /// Overload for . - public static IObservable> StartWithEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.StartWith(PagedChangeSet.Empty); - - /// - /// The type of the object. - /// The type of the key. - /// The grouping key type. - /// The source to prepend an empty changeset to. - /// An observable that emits an empty group changeset first, then all source changesets. - /// Overload for . - public static IObservable> StartWithEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull => source.StartWith(GroupChangeSet.Empty); - - /// - /// The type of the object. - /// The type of the key. - /// The grouping key type. - /// The source to prepend an empty changeset to. - /// An observable that emits an empty immutable group changeset first, then all source changesets. - /// Overload for . - public static IObservable> StartWithEmpty(this IObservable> source) - where TObject : notnull - where TKey : notnull - where TGroupKey : notnull => source.StartWith(ImmutableGroupChangeSet.Empty); - - /// - /// The type of the item. - /// The source of to prepend an empty changeset to. - /// An observable that emits an empty collection first, then all source collections. - /// Overload for . - public static IObservable> StartWithEmpty(this IObservable> source) => source.StartWith(ReadOnlyCollectionLight.Empty); - - /// - /// The source to prepend an initial item to. - /// The item to prepend. The key is extracted from . - /// Overload for items that implement . Delegates to the explicit key overload. - public static IObservable> StartWithItem(this IObservable> source, TObject item) - where TObject : IKey - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.StartWithItem(item, item.Key); - } - - /// - /// Prepends a changeset containing a single Add for the given item and key to the source stream. - /// The Rx equivalent of StartWith, but wrapped as a DynamicData changeset. - /// - /// The type of the object. - /// The type of the key. - /// The source to prepend an initial item to. - /// The item to prepend. - /// The key for the item. - /// An observable that emits a single-item Add changeset first, then all source changesets. - public static IObservable> StartWithItem(this IObservable> source, TObject item, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - var change = new Change(ChangeReason.Add, key, item); - return source.StartWith(new ChangeSet { change }); - } - - /// - /// Creates an subscription per item via . - /// Subscriptions are created on Add/Update and disposed on Update/Remove. All active subscriptions - /// are disposed when the stream completes, errors, or the subscription is disposed. - /// - /// The type of the object. - /// The type of the key. - /// The source to create a subscription for each item in. - /// A factory that creates an for each item. Called on Add and Update (for the new value). - /// A stream that forwards all changesets from unchanged. - /// - /// - /// Change reason handling: - /// - /// EventBehavior - /// AddCalls , stores the returned . - /// UpdateDisposes the previous subscription, then calls for the new value. - /// RemoveDisposes the subscription for the removed item. - /// RefreshPassed through. No subscription change. - /// - /// - /// - /// Internally implemented using - /// and , so disposal semantics match . - /// - /// - /// Use this to tie per-item side effects (event subscriptions, polling timers, child observable subscriptions) - /// to the lifecycle of items in the cache. - /// - /// - /// or is . - /// - /// - /// - public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); - - return new SubscribeMany(source, subscriptionFactory).Run(); - } - - /// - /// The source to create a subscription for each item in. - /// A factory that creates an for each item. Receives the item and its key. - /// Overload whose factory receives both the item and the key. See for full details. - public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); - - return new SubscribeMany(source, subscriptionFactory).Run(); - } - - /// - /// Suppress refresh notifications. - /// - /// The object of the change set. - /// The key of the change set. - /// The source to strip refresh events. - /// An observable which emits change sets. - public static IObservable> SuppressRefresh(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.WhereReasonsAreNot(ChangeReason.Refresh); - - /// - /// An observable that emits instances. - /// Overload that accepts observable caches. Internally calls Connect() on each cache and delegates to the changeset overload. - public static IObservable> Switch(this IObservable> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Select(cache => cache.Connect()).Switch(); - } - - /// - /// Subscribes to the latest inner changeset stream, unsubscribing from the previous one on each switch. - /// When switching, the old source's items are removed and the new source's items are added. - /// - /// The type of the object. - /// The type of the key. - /// An of changeset streams. The operator subscribes to the latest inner stream. - /// A changeset stream reflecting the items from the most recently emitted inner source. - /// - /// On switch: Remove is emitted for all items from the previous source, then Add for all items from the new source. - /// Worth noting: Each switch clears the entire downstream cache before populating from the new source. Subscribers see a full remove-then-add reset on every switch. - /// - public static IObservable> Switch(this IObservable>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new Switch(sources).Run(); - } - - /// - /// Converts the change set into a fully formed collection. Each change in the source results in a new collection. - /// - /// The type of the object. - /// The type of the key. - /// The source to materialize into a collection on each change. - /// An observable which emits the read only collection. - /// - public static IObservable> ToCollection(this IObservable> source) - where TObject : notnull - where TKey : notnull => source.QueryWhenChanged(query => new ReadOnlyCollectionLight(query.Items)); - - /// - /// Bridges a standard Rx observable of individual items into a DynamicData changeset stream. - /// Each emission becomes an Add (or Update if the key already exists). - /// Supports optional per-item expiration and size limiting. - /// - /// The type of the object. - /// The type of the key. - /// The source to convert into a keyed changeset stream. - /// A that selects the unique key for each item. - /// An optional that specifies per-item expiration time. Return for no expiration. - /// The maximum cache size. Oldest items are removed when exceeded. Use -1 for no limit. - /// An optional for expiration timing. - /// An observable changeset stream. - /// or is . - public static IObservable> ToObservableChangeSet( - this IObservable source, - Func keySelector, - Func? expireAfter = null, - int limitSizeTo = -1, - IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return Cache.Internal.ToObservableChangeSet.Create( - source: source, - keySelector: keySelector, - expireAfter: expireAfter, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - } - - /// - /// Bridges a standard Rx observable of item batches into a DynamicData changeset stream. - /// Each batch is processed with AddOrUpdate, producing Add or Update changes per item. - /// Supports optional per-item expiration and size limiting. - /// - /// The type of the object. - /// The type of the key. - /// The source to convert into a keyed changeset stream. - /// A that selects the unique key for each item. - /// An optional that specifies per-item expiration time. Return for no expiration. - /// The maximum cache size. Oldest items are removed when exceeded. Use -1 for no limit. - /// An optional for expiration timing. - /// An observable changeset stream. - /// or is . - public static IObservable> ToObservableChangeSet( - this IObservable> source, - Func keySelector, - Func? expireAfter = null, - int limitSizeTo = -1, - IScheduler? scheduler = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return Cache.Internal.ToObservableChangeSet.Create( - source: source, - keySelector: keySelector, - expireAfter: expireAfter, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - } - - /// - /// Watches a single key in the source changeset stream, emitting Optional.Some(value) when the key - /// is present and when it is removed. Duplicate values are suppressed via . - /// - /// The type of the object. - /// The type of the key. - /// The source to watch a single key in. - /// The key to watch. - /// An that optional comparer to suppress duplicate emissions. Uses default equality if . - /// An observable of that reflects the presence or absence of the specified key. - /// - /// - /// Unlike , this emits None on removal - /// (rather than the removed value), making it possible to distinguish "key is absent" from "key has a value". - /// - /// - /// EventBehavior - /// AddEmits Optional.Some(value) if the key was not previously tracked. - /// UpdateEmits Optional.Some(newValue) if the new value differs from the previous per . Otherwise suppressed. - /// RemoveEmits . - /// RefreshEmits Optional.Some(value) if the value differs from the last emission per . Otherwise suppressed. - /// - /// Worth noting: No emission occurs if the key is not present at subscription time. To get an initial None when the key is absent, use the overload with initialOptionalWhenMissing: true. - /// - /// is . - /// - /// - public static IObservable> ToObservableOptional(this IObservable> source, TKey key, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new ToObservableOptional(source, key, equalityComparer).Run(); - } - - /// - /// Converts an observable cache into an observable optional that emits the value for the given key. - /// - /// The type of the object. - /// The type of the key. - /// The source to watch a single key in. - /// The key value. - /// When , emits an initial with no value if the key is not present in the cache. - /// An optional instance used to determine if an object value has changed. - /// An observable optional. - /// source is null. - /// - /// Worth noting: Uses lock-based coordination. If the key exists synchronously on Connect(), the initial None may or may not be emitted depending on timing. - /// - public static IObservable> ToObservableOptional(this IObservable> source, TKey key, bool initialOptionalWhenMissing, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TKey : notnull - { - if (initialOptionalWhenMissing) - { - var seenValue = false; - var locker = InternalEx.NewLock(); - - var optional = source.ToObservableOptional(key, equalityComparer).Synchronize(locker).Do(_ => seenValue = true); - var missing = Observable.Return(Optional.None()).Synchronize(locker).Where(_ => !seenValue); - - return optional.Merge(missing); - } - - return source.ToObservableOptional(key, equalityComparer); - } - - /// - /// Converts the change set into a fully formed sorted collection. Each change in the source results in a new sorted collection. - /// - /// The type of the object. - /// The type of the key. - /// The sort key. - /// The source to materialize into a sorted collection on each change. - /// The sort function. - /// The sort order. Defaults to ascending. - /// An observable which emits the read only collection. - /// - public static IObservable> ToSortedCollection(this IObservable> source, Func sort, SortDirection sortOrder = SortDirection.Ascending) - where TObject : notnull - where TKey : notnull - where TSortKey : notnull => source.QueryWhenChanged(query => sortOrder == SortDirection.Ascending ? new ReadOnlyCollectionLight(query.Items.OrderBy(sort)) : new ReadOnlyCollectionLight(query.Items.OrderByDescending(sort))); - - /// - /// Converts the change set into a fully formed sorted collection. Each change in the source results in a new sorted collection. - /// - /// The type of the object. - /// The type of the key. - /// The source to materialize into a sorted collection on each change. - /// The sort comparer. - /// An observable which emits the read only collection. - public static IObservable> ToSortedCollection(this IObservable> source, IComparer comparer) - where TObject : notnull - where TKey : notnull => source.QueryWhenChanged( - query => - { - var items = query.Items.AsList(); - items.Sort(comparer); - return new ReadOnlyCollectionLight(items); - }); - - /// - /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). The factory receives only the current item. - /// - public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((current, _, _) => transformFactory(current), transformOnRefresh); - } - - /// - /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). The factory receives the current item and key. - public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((current, _, key) => transformFactory(current, key), transformOnRefresh); - } - - /// - /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). - public static IObservable> Transform(this IObservable> source, Func, TKey, TDestination> transformFactory, bool transformOnRefresh) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new Transform(source, transformFactory, transformOnRefresh: transformOnRefresh).Run(); - } - - /// - /// This overload accepts an optional forceTransform predicate filtering by source item only (without the key). The factory receives only the current item. - public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((current, _, _) => transformFactory(current), forceTransform?.ForForced()); - } - - /// - /// This overload accepts an optional forceTransform predicate filtering by source item and key. The factory receives the current item and key. - public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((current, _, key) => transformFactory(current, key), forceTransform); - } - - /// - /// Projects each item in the changeset to a new form using a synchronous transform factory. - /// - /// The type of the transformed items. - /// The type of the source items. - /// The type of the key. - /// The source to transform. - /// The that produces a from the current source item, the previous source item (if any), and the key. - /// An observable that, when it emits a predicate, re-transforms all items for which the predicate returns . Re-transformed items are emitted as changes. If , no forced re-transforms occur. - /// An observable changeset of transformed items. - /// - /// - /// Transform maintains a 1:1 mapping between source and destination items, keyed identically. The factory - /// is called once per Add and once per Update. Removes are forwarded without calling the factory. - /// - /// Change reason handling: - /// - /// Input reasonOutput behavior - /// AddCalls factory, emits Add. - /// UpdateCalls factory (receives current item, previous item, key), emits Update with Previous preserved. - /// RemoveEmits Remove. Factory is NOT called. - /// RefreshForwarded as Refresh without re-transforming. To re-transform on Refresh, use the parameter or the transformOnRefresh overloads. - /// - /// Worth noting: By default, Refresh does NOT re-invoke the transform factory (it is just forwarded). Set transformOnRefresh: true to re-transform on Refresh. - /// - /// When emits a predicate, every cached item is tested against it. - /// Matching items are re-transformed and emitted as Updates. - /// - /// - /// Factory exceptions propagate as , terminating the stream. - /// Use - /// to catch factory errors without killing the stream. - /// - /// - /// - /// - /// - /// or is . - public static IObservable> Transform(this IObservable> source, Func, TKey, TDestination> transformFactory, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - if (forceTransform is not null) - { - return new TransformWithForcedTransform(source, transformFactory, forceTransform).Run(); - } - - return new Transform(source, transformFactory).Run(); - } - - /// - /// This overload accepts of to force re-transformation of ALL items when the observable emits. The factory receives only the current item. - public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable forceTransform) - where TDestination : notnull - where TSource : notnull - where TKey : notnull => source.Transform((cur, _, _) => transformFactory(cur), forceTransform.ForForced()); - - /// - /// This overload accepts of to force re-transformation of ALL items when the observable emits. The factory receives the current item and key. - public static IObservable> Transform(this IObservable> source, Func transformFactory, IObservable forceTransform) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); - - return source.Transform((cur, _, key) => transformFactory(cur, key), forceTransform.ForForced()); - } - - /// - /// This overload accepts of to force re-transformation of ALL items when the observable emits. - public static IObservable> Transform(this IObservable> source, Func, TKey, TDestination> transformFactory, IObservable forceTransform) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); - - return source.Transform(transformFactory, forceTransform.ForForced()); - } - - /// - /// This overload takes a simpler factory that receives only the current item. - /// - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((current, _, _) => transformFactory(current), forceTransform); - } - - /// - /// This overload takes a factory that receives the current item and key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((current, _, key) => transformFactory(current, key), forceTransform); - } - - /// - /// Async version of . - /// Projects each item using an async factory that returns . - /// - /// The type of the transformed items. - /// The type of the source items. - /// The type of the key. - /// The source to transform asynchronously. - /// The async function that produces a from the current source item, the previous source item (if any), and the key. - /// An observable that, when it emits a predicate, re-transforms all items for which the predicate returns . Re-transformed items are emitted as changes. If , no forced re-transforms occur. - /// An observable changeset of transformed items. - /// - /// - /// Transforms within a single changeset batch execute concurrently. The entire batch must complete - /// before the resulting changeset is emitted. Use the overloads - /// to control maximum concurrency and Refresh handling. - /// - /// Change reason handling: - /// - /// Input reasonOutput behavior - /// AddAwaits factory, emits Add. - /// UpdateAwaits factory (receives current, previous, key), emits Update. - /// RemoveEmits Remove. Factory is NOT called. - /// RefreshForwarded as Refresh by default. Use to re-transform. - /// - /// Worth noting: Transforms are batched per changeset (all tasks must complete before the next changeset is processed). Completion waits for in-flight transforms. Remove does NOT cancel in-flight transforms for the removed key. - /// - /// Factory exceptions propagate as . Use - /// - /// to catch factory errors without terminating the stream. - /// - /// - /// or is . - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new TransformAsync(source, transformFactory, null, forceTransform).Run(); - } - - /// - /// This overload accepts to control concurrency and Refresh handling. The factory receives only the current item. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, TransformAsyncOptions options) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((current, _, _) => transformFactory(current), options); - } - - /// - /// This overload accepts to control concurrency and Refresh handling. The factory receives the current item and key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func> transformFactory, TransformAsyncOptions options) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((current, _, key) => transformFactory(current, key), options); - } - - /// - /// This overload accepts to control concurrency and Refresh handling. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, TransformAsyncOptions options) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new TransformAsync(source, transformFactory, null, null, options.MaximumConcurrency, options.TransformOnRefresh).Run(); - } - - /// - /// Optimized transform for immutable items with deterministic (pure) transform functions. - /// Refresh changes are dropped entirely since immutable items cannot change in place. - /// - /// The type of the transformed items. - /// The type of the source items. - /// The type of the key. - /// The source to transform (items assumed immutable). - /// The pure function that maps a source item to a destination item. Must be deterministic: same input always produces equivalent output. - /// An observable changeset of transformed items. - /// - /// - /// Because the transform is assumed to be stateless and deterministic, this operator does not track - /// previously transformed items. This reduces memory overhead compared to . - /// - /// Change reason handling: - /// - /// Input reasonOutput behavior - /// AddCalls factory, emits Add. - /// UpdateCalls factory, emits Update. - /// RemoveEmits Remove. Factory is NOT called. - /// RefreshDROPPED. Immutable items do not change, so Refresh is meaningless. - /// - /// Use this when items are immutable, the factory is pure, and the factory is cheap. If any of these conditions are false, use instead. - /// - /// or is . - public static IObservable> TransformImmutable( - this IObservable> source, - Func transformFactory) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new TransformImmutable( - source: source, - transformFactory: transformFactory) - .Run(); - } - - /// - /// Flattens each source item into zero or more destination items (1:N), producing a single flat changeset. - /// Each child item must have a globally unique key across all parents. - /// - /// The type of the child items. - /// The type of the child item keys. - /// The type of the source (parent) items. - /// The type of the source (parent) keys. - /// The source to expand each item into multiple children. - /// A function that expands a parent item into its children. For or overloads, subsequent changes to the child collection are automatically tracked. - /// A that extracts a unique key from each child item. Keys must be unique across ALL parents, not just within one parent. - /// An observable changeset of flattened child items. - /// - /// Change reason handling: - /// - /// Input reasonOutput behavior - /// AddCalls , emits Add for each child. - /// UpdateDiffs old children vs new children: emits Remove for removed children, Add for new children, Update for children with matching keys. - /// RemoveEmits Remove for all children of the removed parent. - /// RefreshPropagated as Refresh to all children (no re-expansion). - /// - /// Worth noting: If two source items produce children with the same key, last-in-wins. Refresh does NOT re-expand children (only Update does). - /// If two parents produce children with the same key, last-in-wins. Use the async variant with a to control conflict resolution. - /// - /// , , or is . - /// - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); - - /// - /// This overload accepts an selector. Changes to the child collection (adds, removes, replacements) are automatically observed and reflected downstream. - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); - - /// - /// This overload accepts a selector. Changes to the child collection are automatically observed and reflected downstream. - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); - - /// - /// This overload accepts an selector. The child cache is live: subsequent changes to it are automatically propagated downstream. - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, Func keySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => new TransformMany(source, manySelector, keySelector).Run(); - - /// - /// Async version of . - /// Flattens each source item into zero or more destination items using an async factory. - /// - /// The type of the child items. - /// The type of the child item keys. - /// The type of the source (parent) items. - /// The type of the source (parent) keys. - /// The source to expand each item into multiple children asynchronously. - /// An async function that expands a parent item (and its key) into an of children. - /// A that extracts a unique key from each child item. - /// An that optional comparer to determine if two child items with the same key are equal. Used to suppress no-op updates. - /// An that optional comparer to resolve key collisions when the same destination key is produced by multiple parents. The winning item is determined by this comparer. - /// An observable changeset of flattened child items. - /// - /// - /// Because each parent's expansion is async, child collections may arrive via separate changesets - /// (unlike the synchronous TransformMany which batches all children into one changeset). - /// - /// - /// Factory exceptions propagate as . Use - /// - /// to catch errors without killing the stream. - /// - /// - /// or is . - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - - return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer).Run(); - } - - /// - /// This overload takes a factory that receives only the source item (without the key). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => source.TransformManyAsync((val, _) => manySelector(val), keySelector, equalityComparer, comparer); - - /// - /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives the source item and its key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManyAsync(this IObservable> source, Func> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - where TCollection : INotifyCollectionChanged, IEnumerable - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - - return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer).Run(); - } - - /// - /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives only the source item. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManyAsync(this IObservable> source, Func> manySelector, Func keySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - where TCollection : INotifyCollectionChanged, IEnumerable => source.TransformManyAsync((val, _) => manySelector(val), keySelector, equalityComparer, comparer); - - /// - /// This overload returns an per parent. The child cache is live: its changes propagate downstream. No keySelector is needed since the cache already has keys. The factory receives the source item and its key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - - return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector), equalityComparer, comparer).Run(); - } - - /// - /// This overload returns an per parent. The child cache is live. The factory receives only the source item. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManyAsync(this IObservable> source, Func>> manySelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => source.TransformManyAsync((val, _) => manySelector(val), equalityComparer, comparer); - - /// - /// Async version of - /// with error handling. Factory exceptions are caught and routed to instead of - /// terminating the stream. - /// - /// The type of the child items. - /// The type of the child item keys. - /// The type of the source (parent) items. - /// The type of the source (parent) keys. - /// The source to expand each item into multiple children asynchronously with error handling. - /// An async function that expands a parent item (and its key) into an of children. - /// A that extracts a unique key from each child item. - /// A that called when throws. The faulting item is skipped and the stream continues. - /// An that optional comparer to determine if two child items with the same key are equal. - /// An that optional comparer to resolve key collisions when the same destination key is produced by multiple parents. - /// An observable changeset of flattened child items. - /// Because the transformations are asynchronous, each sub-collection may be emitted via a separate changeset. - /// , , or is . - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer, errorHandler).Run(); - } - - /// - /// This overload takes a factory that receives only the source item (without the key). - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => source.TransformManySafeAsync((val, _) => manySelector(val), keySelector, errorHandler, equalityComparer, comparer); - - /// - /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives the source item and its key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManySafeAsync(this IObservable> source, Func> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - where TCollection : INotifyCollectionChanged, IEnumerable - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector, keySelector), equalityComparer, comparer, errorHandler).Run(); - } - - /// - /// This overload returns an observable collection (of type implementing both and ) whose changes are tracked live. The factory receives only the source item. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManySafeAsync(this IObservable> source, Func> manySelector, Func keySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - where TCollection : INotifyCollectionChanged, IEnumerable => source.TransformManySafeAsync((val, _) => manySelector(val), keySelector, errorHandler, equalityComparer, comparer); - - /// - /// This overload returns an per parent. The child cache is live. The factory receives the source item and its key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return new TransformManyAsync(source, CreateChangeSetTransformer(manySelector), equalityComparer, comparer, errorHandler).Run(); - } - - /// - /// This overload returns an per parent. The child cache is live. The factory receives only the source item. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformManySafeAsync(this IObservable> source, Func>> manySelector, Action> errorHandler, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => source.TransformManySafeAsync((val, _) => manySelector(val), errorHandler, equalityComparer, comparer); - - /// - /// Projects each item into a per-item observable. The latest value emitted by each item's observable - /// becomes the transformed value in the output changeset. - /// - /// The type of the source items. - /// The type of the key. - /// The type of the transformed items. - /// The source to transform using per-item observables. - /// A function that, given a source item and its key, returns an whose emissions become the transformed values. - /// An observable changeset where each key's value is the latest emission from its per-item observable. - /// - /// - /// Source changeset handling (parent events): - /// - /// - /// EventBehavior - /// AddCalls and subscribes to the returned observable. The item is not visible downstream until the observable emits its first value. - /// UpdateDisposes the old item's observable subscription and subscribes to the new item's observable. The item disappears from downstream until the new observable emits. - /// RemoveDisposes the item's observable subscription. If the item was visible downstream, a Remove is emitted. - /// RefreshForwarded as Refresh if the item is currently visible downstream. Otherwise dropped. - /// - /// - /// Per-item observable handling (transform observable events): - /// - /// - /// EmissionBehavior - /// First valueThe transformed item appears downstream as an Add. - /// Subsequent valuesEach new value replaces the previous one: an Update is emitted downstream. - /// ErrorTerminates the entire output stream. - /// CompletedThe item remains at its last emitted value. No further updates are possible for this item. - /// - /// - /// Worth noting: Items are invisible downstream until their per-item observable emits at least one value. - /// If an item's observable never emits, that item never appears in the output. The transform factory's selector - /// runs under an internal lock, so it must not synchronously access other DynamicData caches (deadlock risk in - /// cross-cache pipelines). The output completes when the source completes and all per-item observables have - /// also completed. - /// - /// - /// or is . - /// - /// - /// - public static IObservable> TransformOnObservable(this IObservable> source, Func> transformFactory) - where TSource : notnull - where TKey : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new TransformOnObservable(source, transformFactory).Run(); - } - - /// - /// This overload takes a factory that receives only the source item (without the key). - public static IObservable> TransformOnObservable(this IObservable> source, Func> transformFactory) - where TSource : notnull - where TKey : notnull - where TDestination : notnull - { - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformOnObservable((obj, _) => transformFactory(obj)); - } - - /// - /// This overload accepts a simpler factory that receives only the current item, and a forceTransform predicate filtering by source item only. - public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformSafe((current, _, _) => transformFactory(current), errorHandler, forceTransform.ForForced()); - } - - /// - /// This overload accepts a factory that receives the current item and key. - public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformSafe((current, _, key) => transformFactory(current, key), errorHandler, forceTransform); - } - - /// - /// Projects each item using a synchronous factory, catching factory exceptions via a mandatory error handler - /// instead of terminating the stream. - /// - /// The type of the transformed items. - /// The type of the source items. - /// The type of the key. - /// The source to transform with error handling. - /// The that produces a from the current source item, the previous source item (if any), and the key. - /// A callback invoked when throws. Receives an containing the exception and the faulting item. The item is skipped and the stream continues. - /// An optional that, when it emits a predicate, re-transforms all items for which the predicate returns . If , no forced re-transforms occur. - /// An observable changeset of transformed items. - /// - /// - /// Behaves identically to - /// except that factory exceptions are routed to instead of propagating as . - /// Source-level errors (i.e. the source observable itself erroring) still propagate normally. - /// - /// Worth noting: Factory exceptions are caught per-item; the faulting item is skipped and reported to the error handler while the stream continues. Source-level errors still terminate the stream. - /// - /// , , or is . - public static IObservable> TransformSafe(this IObservable> source, Func, TKey, TDestination> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - if (forceTransform is not null) - { - return new TransformWithForcedTransform(source, transformFactory, forceTransform, errorHandler).Run(); - } - - return new Transform(source, transformFactory, errorHandler).Run(); - } - - /// - /// This overload accepts of to force re-transformation of ALL items. The factory receives only the current item. - public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable forceTransform) - where TDestination : notnull - where TSource : notnull - where TKey : notnull => source.TransformSafe((cur, _, _) => transformFactory(cur), errorHandler, forceTransform.ForForced()); - - /// - /// This overload accepts of to force re-transformation of ALL items. The factory receives the current item and key. - public static IObservable> TransformSafe(this IObservable> source, Func transformFactory, Action> errorHandler, IObservable forceTransform) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); - - return source.TransformSafe((cur, _, key) => transformFactory(cur, key), errorHandler, forceTransform.ForForced()); - } - - /// - /// This overload accepts of to force re-transformation of ALL items. - public static IObservable> TransformSafe(this IObservable> source, Func, TKey, TDestination> transformFactory, Action> errorHandler, IObservable forceTransform) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - forceTransform.ThrowArgumentNullExceptionIfNull(nameof(forceTransform)); - - return source.TransformSafe(transformFactory, errorHandler, forceTransform.ForForced()); - } - - /// - /// This overload takes a factory that receives only the current item. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformSafeAsync((current, _, _) => transformFactory(current), errorHandler, forceTransform); - } - - /// - /// This overload takes a factory that receives the current item and key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformSafeAsync((current, _, key) => transformFactory(current, key), errorHandler, forceTransform); - } - - /// - /// Async version of . - /// Projects each item using an async factory, catching factory exceptions via a mandatory error handler. - /// - /// The type of the transformed items. - /// The type of the source items. - /// The type of the key. - /// The source to transform asynchronously with error handling. - /// The async function that produces a . - /// A that called when throws or faults. The item is skipped and the stream continues. - /// An optional that forces re-transformation of matching items. - /// An observable changeset of transformed items. - /// Combines the async execution model of with the error-safe behavior of . - /// , , or is . - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return new TransformAsync(source, transformFactory, errorHandler, forceTransform).Run(); - } - - /// - /// This overload accepts to control concurrency and Refresh handling. The factory receives only the current item. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, TransformAsyncOptions options) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformSafeAsync((current, _, _) => transformFactory(current), errorHandler, options); - } - - /// - /// This overload accepts to control concurrency and Refresh handling. The factory receives the current item and key. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func> transformFactory, Action> errorHandler, TransformAsyncOptions options) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformSafeAsync((current, _, key) => transformFactory(current, key), errorHandler, options); - } - - /// - /// This overload accepts to control concurrency and Refresh handling. - [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, TransformAsyncOptions options) - where TDestination : notnull - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return new TransformAsync(source, transformFactory, errorHandler, null, options.MaximumConcurrency, options.TransformOnRefresh).Run(); - } - - /// - /// Builds a hierarchical tree from a flat changeset using a parent key selector. - /// Each item becomes a with Parent, Children, Depth, and IsRoot properties. - /// - /// The type of the source items. Must be a reference type. - /// The type of the key. - /// The source to transform into a hierarchical tree. - /// The that returns the key of an item's parent. Return the item's own key (or a non-existent key) for root items. - /// An optional that emits a filter predicate for nodes. When the predicate changes, nodes are re-evaluated and filtered. - /// An observable changeset of items representing the tree. - /// - /// Change reason handling: - /// - /// Input reasonOutput behavior - /// AddCreates node, attaches to parent (or root if parent not found), emits Add. - /// UpdateUpdates node. If returns a different parent key, the node is re-parented. - /// RemoveRemoves node. Orphaned children become root nodes. - /// RefreshRe-evaluates parent key. May re-parent the node if the parent changed. - /// - /// Circular references are NOT detected. If item A is the parent of B and B is the parent of A, behavior is undefined. - /// - /// or is . - public static IObservable, TKey>> TransformToTree(this IObservable> source, Func pivotOn, IObservable, bool>>? predicateChanged = null) - where TObject : class - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pivotOn.ThrowArgumentNullExceptionIfNull(nameof(pivotOn)); - - return new TreeBuilder(source, pivotOn, predicateChanged).Run(); - } - - /// - /// This overload defaults to transformOnRefresh: false and does not provide an error handler (factory exceptions propagate as OnError). - public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction) - where TDestination : class - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); - - return source.TransformWithInlineUpdate(transformFactory, updateAction, false); - } - - /// - /// This overload does not provide an error handler (factory exceptions propagate as OnError). The transformOnRefresh parameter controls Refresh behavior. - public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction, bool transformOnRefresh) - where TDestination : class - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); - - return new TransformWithInlineUpdate(source, transformFactory, updateAction, transformOnRefresh: transformOnRefresh).Run(); - } - - /// - /// This overload defaults to transformOnRefresh: false but includes an error handler for factory/update action exceptions. - public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction, Action> errorHandler) - where TDestination : class - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return source.TransformWithInlineUpdate(transformFactory, updateAction, errorHandler, false); - } - - /// - /// Projects each item using a transform factory for Add, and mutates the existing transformed - /// item in place (via an update action) for Update, preserving the original object reference. - /// - /// The type of the transformed items. Must be a reference type since items are mutated in place. - /// The type of the source items. - /// The type of the key. - /// The source to transform with in-place mutation on updates. - /// A that called on Add (and optionally Refresh) to create a new . - /// A that called on Update. Receives (existingTransformed, newSource). Mutate the existing transformed item to reflect the new source value. Example: (vm, model) => vm.Value = model.Value. - /// A that called when or throws. The faulting item is skipped. - /// When , Refresh changes call on the existing item. - /// An observable changeset of transformed items. - /// - /// - /// This is useful when the destination type is a ViewModel that should maintain its identity across updates. - /// Instead of replacing the entire ViewModel, the update action patches the existing instance. - /// - /// Change reason handling: - /// - /// Input reasonOutput behavior - /// AddCalls , emits Add. - /// UpdateCalls on the EXISTING transformed item (same reference), emits Update. - /// RemoveEmits Remove. - /// RefreshIf is true, calls . Otherwise forwarded as Refresh. - /// - /// - /// , , , or is . - public static IObservable> TransformWithInlineUpdate(this IObservable> source, Func transformFactory, Action updateAction, Action> errorHandler, bool transformOnRefresh) - where TDestination : class - where TSource : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - updateAction.ThrowArgumentNullExceptionIfNull(nameof(updateAction)); - errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); - - return new TransformWithInlineUpdate(source, transformFactory, updateAction, errorHandler, transformOnRefresh).Run(); - } - - /// - /// Converts moves changes to remove + add. - /// - /// The type of the object. - /// The type of the key. - /// The source to convert move events into remove/add pairs. - /// the same SortedChangeSets, except all moves are replaced with remove + add. - public static IObservable> TreatMovesAsRemoveAdd(this IObservable> source) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - static IEnumerable> ReplaceMoves(IChangeSet items) - { - foreach (var change in items.ToConcreteType()) - { - if (change.Reason == ChangeReason.Moved) - { - yield return new Change(ChangeReason.Remove, change.Key, change.Current, change.PreviousIndex); - - yield return new Change(ChangeReason.Add, change.Key, change.Current, change.CurrentIndex); - } - else - { - yield return change; - } - } - } - - return source.Select(changes => new SortedChangeSet(changes.SortedItems, ReplaceMoves(changes))); - } - - /// - /// Emits when all items in the cache satisfy a condition based on their per-item observable, - /// and otherwise. Re-evaluates whenever the cache changes or any per-item observable emits. - /// - /// The type of the object. - /// The type of the key. - /// The type of the value emitted by each per-item observable. - /// The source to evaluate a condition across all items in. - /// A factory that produces a condition observable for each item. - /// A that predicate applied to each per-item observable's latest value. - /// An observable of bool that emits whenever the all-items condition changes. - /// , , or is . - /// - /// - /// EventBehavior - /// AddA new per-item subscription is created. The aggregate condition is recalculated. - /// UpdateThe item is replaced in the collection snapshot. Condition recalculated. - /// RemovePer-item subscription disposed. Condition recalculated over remaining items. - /// RefreshNo effect on per-item subscriptions. Condition not recalculated unless the per-item observable emits. - /// - /// Worth noting: Items whose per-item observable has not yet emitted are treated as not satisfying the condition. An empty cache is vacuously . The result uses DistinctUntilChanged, so duplicate bool values are suppressed. - /// - /// - public static IObservable TrueForAll(this IObservable> source, Func> observableSelector, Func equalityCondition) - where TObject : notnull - where TKey : notnull - where TValue : notnull => source.TrueFor(observableSelector, items => items.All(o => o.LatestValue.HasValue && equalityCondition(o.LatestValue.Value))); - - /// - /// - /// Produces a boolean observable indicating whether the latest resulting value from all of the specified observables matches - /// the equality condition. The observable is re-evaluated whenever. - /// - /// - /// i) The cache changes - /// or ii) The inner observable changes. - /// - /// - /// The type of the object. - /// The type of the key. - /// The type of the value. - /// The source to evaluate a condition across all items in. - /// A that selector which returns the target observable. - /// The equality condition. - /// An observable which boolean values indicating if true. - /// source. - public static IObservable TrueForAll(this IObservable> source, Func> observableSelector, Func equalityCondition) - where TObject : notnull - where TKey : notnull - where TValue : notnull => source.TrueFor(observableSelector, items => items.All(o => o.LatestValue.HasValue && equalityCondition(o.Item, o.LatestValue.Value))); - - /// - /// Emits when any item in the cache satisfies a condition based on its per-item observable, - /// and when none do. Re-evaluates whenever the cache changes or any per-item observable emits. - /// - /// The type of the object. - /// The type of the key. - /// The type of the value emitted by each per-item observable. - /// The source to evaluate a condition across any item in. - /// A factory that produces a condition observable for each item. - /// A that predicate applied to each item and its per-item observable's latest value. - /// An observable of bool that emits whenever the any-item condition changes. - /// , , or is . - /// - /// - /// EventBehavior - /// AddA new per-item subscription is created. The aggregate condition is recalculated. - /// UpdateThe item is replaced in the collection snapshot. Condition recalculated. - /// RemovePer-item subscription disposed. Condition recalculated over remaining items. - /// RefreshNo effect on per-item subscriptions. Condition not recalculated unless the per-item observable emits. - /// - /// Worth noting: Items whose per-item observable has not yet emitted are treated as not satisfying the condition. An empty cache yields . The result uses DistinctUntilChanged, so duplicate bool values are suppressed. - /// - /// - public static IObservable TrueForAny(this IObservable> source, Func> observableSelector, Func equalityCondition) - where TObject : notnull - where TKey : notnull - where TValue : notnull => source.TrueFor(observableSelector, items => items.Any(o => o.LatestValue.HasValue && equalityCondition(o.Item, o.LatestValue.Value))); - - /// - /// The source to evaluate a condition across any item in. - /// A factory that produces a condition observable for each item. - /// A that predicate applied to each per-item observable's latest value (without the item). - /// This overload accepts a predicate that takes only the value, not the item. Useful when the condition depends only on the observed value. - public static IObservable TrueForAny(this IObservable> source, Func> observableSelector, Func equalityCondition) - where TObject : notnull - where TKey : notnull - where TValue : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - equalityCondition.ThrowArgumentNullExceptionIfNull(nameof(equalityCondition)); - - return source.TrueFor(observableSelector, items => items.Any(o => o.LatestValue.HasValue && equalityCondition(o.LatestValue.Value))); - } - - /// - /// Sets the Index property on each item (which must implement ) - /// to reflect its position in the sorted output. Operates on . - /// - /// The type of the object. - /// The type of the key. - /// The source to update index positions in. - /// An observable that emits the sorted changesets after updating item indices. - public static IObservable> UpdateIndex(this IObservable> source) - where TObject : IIndexAware - where TKey : notnull => source.Do(changes => changes.SortedItems.Select((update, index) => new { update, index }).ForEach(u => u.update.Value.Index = u.index)); - - /// - /// Filters the source changeset stream to a single key, emitting each for that key. - /// Changes for all other keys are ignored. - /// - /// The type of the object. - /// The type of the key. - /// The source to watch a single key in. - /// The key to observe. - /// An observable of for the specified key only. - /// - /// - /// Emits Add, Update, Remove, and Refresh changes as they occur for the target key. - /// No initial emission occurs if the key is not yet present in the cache. This operator does not - /// produce changesets; it produces individual change notifications. For Optional-based watching, - /// use . - /// - /// - /// - /// - public static IObservable> Watch(this IObservable> source, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.SelectMany(updates => updates).Where(update => update.Key.Equals(key)); - } - - /// - /// Filters the source changeset stream to a single key, emitting the current value each time it changes. - /// Even emits the value on removal (the removed item's value). - /// - /// The type of the object. - /// The type of the key. - /// The source to watch a single key in. - /// The key to observe. - /// An observable of the item's value whenever it changes for the specified key. - /// - /// - /// Unlike , - /// this does not emit on removal. It emits the removed item's value instead. - /// If you need to distinguish presence from absence, use ToObservableOptional. - /// - /// - /// EventBehavior - /// AddEmits the added item's value. - /// UpdateEmits the new value. - /// RemoveEmits the removed item's value (not None; use if you need removal detection). - /// RefreshEmits the current value. - /// - /// Worth noting: No emission occurs if the key is not present at subscription time. Changes to other keys are ignored entirely. - /// - /// - /// - public static IObservable WatchValue(this IObservableCache source, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Watch(key).Select(u => u.Current); - } - - /// - /// The source to watch a single key in. - /// The key to observe. - /// This overload extends IObservable<> instead of . - public static IObservable WatchValue(this IObservable> source, TKey key) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Watch(key).Select(u => u.Current); - } - - /// - /// Emits an item whenever any of its properties change via . - /// Subscribes to PropertyChanged on each cache item using MergeMany. - /// - /// The type of the object (must implement ). - /// The type of the key. - /// The source to observe property changes on items in. - /// The specific property names to monitor. If empty, all property changes trigger emissions. - /// An observable that emits the item itself each time a monitored property changes. - /// - /// - /// Subscriptions are managed per item: created on Add, replaced on Update, disposed on Remove. - /// Errors from individual property subscriptions are silently ignored. The output is not a changeset - /// stream; it is a plain IObservable<TObject?>. If the same item changes multiple properties - /// rapidly, each change emits the item separately (no deduplication). - /// - /// - /// EventBehavior - /// AddSubscribes to PropertyChanged on the new item. - /// UpdateDisposes the old item's subscription and subscribes to the new item. - /// RemoveDisposes the item's PropertyChanged subscription. - /// RefreshNo effect on subscriptions. - /// OnErrorErrors from individual property subscriptions are silently ignored. Source errors terminate the stream. - /// - /// - /// - /// - /// - /// - public static IObservable WhenAnyPropertyChanged(this IObservable> source, params string[] propertiesToMonitor) - where TObject : INotifyPropertyChanged - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeMany(t => t.WhenAnyPropertyChanged(propertiesToMonitor)); - } - - /// - /// Emits a (item + property value) whenever the specified property - /// changes on any item in the cache. Subscribes via using MergeMany. - /// - /// The type of the object (must implement ). - /// The type of the key. - /// The type of the monitored property. - /// The source to observe a specific property on items in. - /// A that expression selecting the property to monitor. - /// When (the default), the current property value is emitted immediately for each item upon subscription. - /// An observable of containing both the item and its property value. - /// - /// - /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. Errors from individual - /// property subscriptions are silently ignored. The output is not a changeset stream. If you only need - /// the value (not the owning item), use instead. - /// - /// - /// EventBehavior - /// AddSubscribes to the specified property on the new item. If notifyOnInitialValue is true, the current value is emitted immediately. - /// UpdateDisposes the old item's property subscription and subscribes to the new item. - /// RemoveDisposes the item's property subscription. No further emissions for this item. - /// RefreshNo effect on subscriptions. The existing property subscription continues. - /// OnErrorPer-item property subscription errors are silently ignored. Source errors terminate the stream. - /// - /// - /// - public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) - where TObject : INotifyPropertyChanged - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - return source.MergeMany(t => t.WhenPropertyChanged(propertyAccessor, notifyOnInitialValue)); - } - - /// - /// Emits the property value whenever the specified property changes on any item in the cache. - /// Like but emits only the value, discarding the owning item. - /// - /// The type of the object (must implement ). - /// The type of the key. - /// The type of the monitored property. - /// The source to observe a specific property value on items in. - /// A that expression selecting the property to monitor. - /// When (the default), the current property value is emitted immediately for each item upon subscription. - /// An observable of property values. The owning item is not included; use if you need it. - /// - /// - /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. Errors from individual - /// property subscriptions are silently ignored. If you need to correlate a value back to its source item, - /// use which returns a pair. - /// - /// - /// EventBehavior - /// AddSubscribes to the specified property. If notifyOnInitialValue is true, the current value is emitted immediately. - /// UpdateDisposes the old subscription, subscribes to the new item's property. - /// RemoveDisposes the property subscription. - /// RefreshNo effect on subscriptions. - /// OnErrorPer-item errors silently ignored. Source errors terminate the stream. - /// - /// - /// - /// - /// - /// - public static IObservable WhenValueChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) - where TObject : INotifyPropertyChanged - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - return source.MergeMany(t => t.WhenChanged(propertyAccessor, notifyOnInitialValue)); - } - - /// - /// Includes changes for the specified reasons only. - /// - /// The type of the object. - /// The type of the key. - /// The source to filter by change reason. - /// The values to filter by. - /// An observable which emits a change set with items matching the reasons. - /// reasons. - /// Must select at least on reason. - /// - /// Worth noting: Filtering out Remove changes will cause memory leaks in downstream caches, since items are never cleaned up. - /// - public static IObservable> WhereReasonsAre(this IObservable> source, params ChangeReason[] reasons) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); - - if (reasons.Length == 0) - { - throw new ArgumentException("Must select at least one reason"); - } - - var hashed = new HashSet(reasons); - - return source.Select(updates => new ChangeSet(updates.Where(u => hashed.Contains(u.Reason)))).NotEmpty(); - } - - /// - /// Excludes updates for the specified reasons. - /// - /// The type of the object. - /// The type of the key. - /// The source to filter by excluding change reasons. - /// The values to filter by. - /// An observable which emits a change set with items not matching the reasons. - /// reasons. - /// Must select at least on reason. - /// - /// Worth noting: Filtering out Remove changes will cause memory leaks in downstream caches, since items are never cleaned up. - /// - public static IObservable> WhereReasonsAreNot(this IObservable> source, params ChangeReason[] reasons) - where TObject : notnull - where TKey : notnull - { - reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); - - if (reasons.Length == 0) - { - throw new ArgumentException("Must select at least one reason"); - } - - var hashed = new HashSet(reasons); - - return source.Select(updates => new ChangeSet(updates.Where(u => !hashed.Contains(u.Reason)))).NotEmpty(); - } - - /// - /// Combines multiple changeset streams using logical XOR (symmetric difference). - /// An item appears downstream only if it exists in exactly one source. - /// - /// The type of the object. - /// The type of the key. - /// The source to combine. - /// The additional streams to combine with. - /// A changeset stream containing items present in exactly one source. - /// - /// - /// Items are tracked via reference counting. An item appears downstream only when exactly one - /// source holds it. Adding the same key from a second source removes it from the result; - /// removing from that second source restores it. - /// - /// - /// EventBehavior - /// AddIf the key is now held by exactly one source, an Add is emitted. If adding causes the count to reach 2+, a Remove is emitted (the item is no longer exclusive). - /// UpdateIf the item is currently downstream (count is 1), an Update is emitted. - /// RemoveReference count decremented. If the count drops to exactly 1, an Add is emitted (the item is now exclusive to one source). If it drops to 0, a Remove is emitted. - /// RefreshIf the item is downstream, a Refresh is forwarded. - /// - /// - /// or is . - /// - /// - /// - /// - public static IObservable> Xor(this IObservable> source, params IObservable>[] others) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (others is null || others.Length == 0) - { - throw new ArgumentNullException(nameof(others)); - } - - return source.Combine(CombineOperator.Xor, others); - } - - /// - /// The of streams to combine. - /// This overload accepts a pre-built collection of sources instead of a params array. - public static IObservable> Xor(this ICollection>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Xor); - } - - /// - /// Dynamically apply a logical Xor operator between the items in the outer observable list. - /// Items which are only in one of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of streams to combine. - /// An observable which emits a change set. - public static IObservable> Xor(this IObservableList>> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Xor); - } - - /// - /// Dynamically apply a logical Xor operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits a change set. - public static IObservable> Xor(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Xor); - } - - /// - /// Dynamically apply a logical Xor operator between the items in the outer observable list. - /// Items which are in any of the sources are included in the result. - /// - /// The type of the object. - /// The type of the key. - /// The of changeset streams to combine. - /// An observable which emits a change set. - public static IObservable> Xor(this IObservableList> sources) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Combine(CombineOperator.Xor); - } - - private static IObservable> Combine(this IObservableList> source, CombineOperator type) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return Observable.Create>( - observer => - { - var connections = source.Connect().Transform(x => x.Connect()).AsObservableList(); - var subscriber = connections.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(connections, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList> source, CombineOperator type) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return Observable.Create>( - observer => - { - var connections = source.Connect().Transform(x => x.Connect()).AsObservableList(); - var subscriber = connections.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(connections, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList>> source, CombineOperator type) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DynamicCombiner(source, type).Run(); - } - - private static IObservable> Combine(this ICollection>> sources, CombineOperator type) - where TObject : notnull - where TKey : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - void UpdateAction(IChangeSet updates) - { - try - { - observer.OnNext(updates); - } - catch (Exception ex) - { - observer.OnError(ex); - } - } - - var subscriber = Disposable.Empty; - try - { - var combiner = new Combiner(type, UpdateAction); - subscriber = combiner.Subscribe([.. sources]); - } - catch (Exception ex) - { - observer.OnError(ex); - observer.OnCompleted(); - } - - return subscriber; - }); - } - - private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] combineTarget) - where TObject : notnull - where TKey : notnull - { - combineTarget.ThrowArgumentNullExceptionIfNull(nameof(combineTarget)); - - return Observable.Create>( - observer => - { - void UpdateAction(IChangeSet updates) - { - try - { - observer.OnNext(updates); - } - catch (Exception ex) - { - observer.OnError(ex); - observer.OnCompleted(); - } - } - - var subscriber = Disposable.Empty; - try - { - var list = combineTarget.ToList(); - list.Insert(0, source); - - var combiner = new Combiner(type, UpdateAction); - subscriber = combiner.Subscribe([.. list]); - } - catch (Exception ex) - { - observer.OnError(ex); - observer.OnCompleted(); - } - - return subscriber; - }); - } - - private static IObservable>? ForForced(this IObservable? source) - where TKey : notnull => source?.Select( - _ => - { - static bool Transformer(TSource item, TKey key) => true; - return (Func)Transformer; - }); - - private static IObservable>? ForForced(this IObservable>? source) - where TKey : notnull => source?.Select( - condition => - { - bool Transformer(TSource item, TKey key) => condition(item); - return (Func)Transformer; - }); - - private static IObservable> OnChangeAction(this IObservable> source, Predicate> predicate, Action> changeAction) - where TObject : notnull - where TKey : notnull - { - return source.Do(changes => - { - foreach (var change in changes.ToConcreteType()) - { - if (!predicate(change)) - { - continue; - } - - changeAction(change); - } - }); - } - - // TODO: Apply the Adapter to more places - private static Func AdaptSelector(Func other) - where TObject : notnull - where TKey : notnull - where TResult : notnull => (obj, _) => other(obj); - - private static IObservable> OnChangeAction(this IObservable> source, ChangeReason reason, Action action) - where TObject : notnull - where TKey : notnull - => source.OnChangeAction(change => change.Reason == reason, change => action(change.Current, change.Key)); - - private static IObservable TrueFor(this IObservable> source, Func> observableSelector, Func>, bool> collectionMatcher) - where TObject : notnull - where TKey : notnull - where TValue : notnull => new TrueFor(source, observableSelector, collectionMatcher).Run(); - - private static Func>>> CreateChangeSetTransformer(Func>> manySelector, Func keySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => async (val, key) => (await manySelector(val, key).ConfigureAwait(false)).AsObservableChangeSet(keySelector); - - private static Func>>> CreateChangeSetTransformer(Func> manySelector, Func keySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull - where TCollection : INotifyCollectionChanged, IEnumerable => async (val, key) => (await manySelector(val, key).ConfigureAwait(false)).ToObservableChangeSet().AddKey(keySelector); - - private static Func>>> CreateChangeSetTransformer(Func>> manySelector) - where TDestination : notnull - where TDestinationKey : notnull - where TSource : notnull - where TSourceKey : notnull => async (val, key) => (await manySelector(val, key).ConfigureAwait(false)).Connect(); } diff --git a/src/DynamicData/DynamicData.csproj b/src/DynamicData/DynamicData.csproj index df4385404..f714918e7 100644 --- a/src/DynamicData/DynamicData.csproj +++ b/src/DynamicData/DynamicData.csproj @@ -34,4 +34,9 @@ Dynamic Data is a comprehensive caching and data manipulation solution which int + + + + + \ No newline at end of file From fc6c09e4347094e20956cf6fd0d81100a4e52719 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 14 Jun 2026 23:48:23 -0700 Subject: [PATCH 06/14] WhenPropertyChanged: don't drop events fired during subscribe (#1111) * Fix TOCTOU race in WhenPropertyChanged/WhenValueChanged ObservablePropertyFactory used initial.Concat(events) for both the shallow and deep-chain forms. Concat subscribes to the second source (the PropertyChanged event handler) only AFTER the first (the initial value) completes. Any PropertyChanged notification that fired during that gap was silently dropped. The deep-chain form had an additional gap: Take(1).Repeat tore down all chain notifiers and then re-subscribed via GetNotifiers, losing any events that fired during the re-walk. Fix: 1. Shallow form: rewrite with Observable.Create. Attach the PropertyChanged event handler FIRST so no events are missed during the subscribe window. Use Interlocked.CompareExchange on initialClaimed to ensure exactly one first emission (either the initial or the first handler-fired event, whichever wins the race). A one-shot Interlocked-CAS dedup guard catches the rare setter-update-then-notify duplicate that the CAS cannot otherwise distinguish. 2. Deep-chain form: per-level SerialDisposable. ResubscribeFrom(level) atomically swaps each level's subscription slot to the new value's notifier (subscribe new before disposing old via SerialDisposable.Disposable=). At all times, every live chain level has an active notifier; no re-walk gap. Initial-emit uses the same CAS+dedup pattern as the shallow form. Both fixes are lock-free: only Interlocked.CompareExchange and Volatile read/write. The one-shot dedup guard uses EqualityComparer.Default exactly once per subscription, at the boundary between the initial and the first handler emission, not as a continuous DistinctUntilChanged. Regression tests in WhenPropertyChangedRaceFixture force the race deterministically by parking the observer's OnNext for the initial value while a separate thread mutates the property. Verified RED on main (3 of 4 tests fail), GREEN with fix (4 of 4 pass). Stability check 20/20. Tests: Binding suite 145/145 pass. Full suite 2339/2339 pass (excluding one pre-existing flake unrelated to this branch: SuspendNotificationsFixture.ConcurrentSuspendDuringResumeDoesNotCorrupt which fails on main too). * Deep chain: drainer-based re-walk eliminates concurrent-mutation race The per-level SerialDisposable approach still allowed events to be dropped when two threads concurrently mutated the same intermediate property. Both fired the same notifier; both ResubscribeFrom calls raced; whichever SerialDisposable.Disposable= swap landed last won, even if that thread's pre-walk had read a stale value. The slot would end up subscribed to the LOSER of the property setter race, and subsequent events on the actual current value were lost. Add a single-drainer pattern: notifier handlers signal _minDirtyLevel (Interlocked CAS loop on the minimum dirty level) and the winner of an Interlocked CAS on _drainerActive runs the actual re-walk. Others return immediately. The drainer loops until no signals remain, then re-checks once more to catch signals that arrived during the release. Initial subscription claims the drainer for the duration of ResubscribeFrom(0) + initial Emit so concurrent fires queue and process after. All work serialized through a single thread; no concurrent re-walks possible; the FINAL slot state always reflects the LATEST chain state because the drainer's last iteration always reads the current value. Lock-free. Only Interlocked, Volatile, and SerialDisposable's atomic swap. DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped: statistical test (500 iterations) that triggered the race in ~10 percent of runs on the prior implementation; now 0 of 500. Stability check 10/10. DeepChain_FiveLevels_MidChainSwap_DeeperLevelsRetargetCorrectly: structural test for the depth-5 mid-chain re-attach case. Tests: Binding suite 147/147 pass. * Simplify deep-chain via recursive Switch composition The drainer pattern was overengineered. The Rx-idiomatic shape for `observe a property chain where each level can be reassigned` is a recursive composition: each level is an ObserveLevel emitting current-then-changes, and the chain is built with .Select(child => deeper).Switch(). When a parent fires, Switch atomically subscribes to the new deeper chain and disposes the old; no SerialDisposable bookkeeping, no min-dirty-level signaling, no CAS-claimed drainer. ObserveLevel attaches the PropertyChanged handler BEFORE reading the initial value (same shallow-form fix), so events fired during the per-level subscribe window are not missed. The outer subscriber still applies the CAS-based first-emission-wins and one-shot dedup at the boundary to handle the initial-emit race. Trade-off: concurrent mutations of the SAME observed property from multiple threads (which is an Rx contract violation by the caller) can leave Switch's lock-acquisition order out of sync with the user's setter-completion order. Well-behaved INPC usage serializes mutations on observed properties; the simplified design relies on that contract. Removed the DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped test and the block-observer-during-initial-emit deep-chain test (the latter deadlocked against Switch's internal lock by design). Net diff: -242 lines. ObservablePropertyFactory shrank from ~330 lines to ~175. Binding suite 145/145 pass. * Serialize deep-chain via SharedDeliveryQueue Replace the recursive Switch composition with a single SharedDeliveryQueue that funnels two sub-queues: a high-index signal queue carrying level-change notifications, and a low-index emission queue for the user observer. The drainer processes signals first (LIFO), running ResubscribeFrom and Emit serialized against itself, then delivers user emissions last so they observe the latest chain layout. An InitialSetupSignal sentinel funnels the initial chain attachment through the same drainer, closing the subscribe gap without taking a separate lock. Switch is removed entirely: its internal gate held during downstream OnNext deadlocked any observer that blocked synchronously, and adding DeliveryQueue downstream of Switch could not break the cycle. Re-adds the two concurrent regression tests that previously deadlocked or relied on the drainer: - DeepChain_ConcurrentLeafMutationDuringInitialEmit_NotDropped - DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped (500 iterations) * Address PR feedback: dedup gating, exception routing, test hygiene Production: - Dedup window is now armed only when notifyInitial is true. When the caller didn't ask for an initial value, two consecutive same-valued PropertyChanged events are both legitimate and must both be delivered; the previous code silently dropped the second one. - Wrap the value accessor / chain walk in try/catch and route exceptions to userSub/queue.OnError. The earlier Rx pipeline got this from Select; the new direct invocation needs it explicitly so a throwing property getter doesn't escape the drainer / PropertyChanged invocation thread. Tests: - Add NotifyInitialFalse_DoesNotDedupSameValuedEvents (shallow + deep) covering the dedup gating fix. - Add timeouts to ManualResetEventSlim.Wait so a failed assertion can't park the observer thread indefinitely. Release observerCanContinue in finally. - Replace Thread.Sleep with bounded SpinWait.SpinUntil(condition, timeout) via a WaitForCondition helper. - Capture and dispose the IDisposable returned by Subscribe inside Task.Run so the PropertyChanged handler is detached at test end. - Remove unused subscribeCompleted local. * Tighten regression-test budgets for CI runners Two follow-ups after CI flaked on heavily-loaded shared runners: - Flatten the deep-chain disposable from nested CompositeDisposable to a single composite via collection-expression spread (avoids the redundant inner CompositeDisposable allocation around levelSlots). - Bump the default WaitForCondition timeout from 5s to 30s and route all ManualResetEventSlim / subscribeTask.Wait calls through it. Locally these waits return in <1ms; the larger budget only matters when CI is under heavy load. - Reduce DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped from 500 to 50 iterations. With SharedDeliveryQueue the outcome is deterministic, so a single iteration proves correctness; 50 is defence in depth. Also drop the unnecessary intermediate WaitForCondition since Task.WaitAll already implies the drainer has fully drained both queued signals. Local: 7/7 race tests pass in ~60ms; 10/10 stability runs clean. * Refactor ObservablePropertyFactory: extract Emitter and DeepChainSubscription Three improvements: - Extract the dedup state machine into a private Emitter : IObserver class. Both factories now wrap their downstream queue in an Emitter; the initialClaimed / dedupArmed / seedValue trio and the PropertyValuesEqual helper live in one place instead of being copy-pasted across two constructors. - Encapsulate the deep-chain runtime in a private DeepChainSubscription : IDisposable class. Fields are default-initialized before the constructor body runs and assigned in well-defined order, which eliminates the DeliverySubQueue? signalSub = null bootstrap (the field is always assigned before any code path that could read it). The InitialSetupSignal sentinel + drainer flow is unchanged. - Reduce the shallow factory to the single-property hot path with a small EmitCurrent helper for the accessor try/catch, removing the second copy of the dedup state machine. Behaviour is unchanged. 148/148 Binding tests pass; race fixture 10/10 stable. * Symmetric SinglePropertySubscription parallel to DeepChainSubscription Extract the shallow-form runtime into a SinglePropertySubscription : IDisposable class with the same shape as DeepChainSubscription: constructor takes (observer, source, [chain-or-name], notifyInitial) and assigns all fields in well-defined order; Dispose tears down handler + queue. The two factories now each become a one-liner Observable.Create that constructs the appropriate subscription. EmitCurrent and OnPropertyChanged become instance methods on SinglePropertySubscription, removing the last shared static helper and keeping all per-subscription state contained. Behaviour unchanged. 148/148 Binding tests pass; race fixture 10/10 stable. * SinglePropertySubscription: route downstream OnNext throws to OnError Match DeepChainSubscription.ProcessSignal's pattern: wrap both the value read AND the emission in try/catch. The downstream observer's OnNext is invoked synchronously by the DeliveryQueue drain, so if it throws, the exception was escaping back out through OnPropertyChanged and into the property setter that fired the event. Route the throw to OnError instead. * Collapse ProcessSignal branches via shared isInitial computation The initial-setup case and the level-fire case only differ in two scalar derivations: (a) where to start the rewalk (0 vs level+1) and (b) whether to emit (always vs only when _notifyInitial). Compute both up front and let the rest of the method be linear. No behaviour change; 148/148 Binding tests pass. * Add multi-threaded torture and AutoRefresh integration tests Two new race fixture tests: 1. DeepChain_FiveLevels_AllLevelsMutatedConcurrently_FinalEmissionMatchesActual Five worker threads each mutate at one level of a depth-5 chain (root subtree swap, mid-level swaps, leaf-int mutations). Many mutations land on detached subtrees and are correctly ignored; mutations on the live chain are processed by the SharedDeliveryQueue drainer in order. After Task.WhenAll the drainer continues until empty; the final emission must equal ReadCurrent() because the last queued signal's ReadCurrent runs against the now-frozen chain state. 50 iterations, 200 mutations per thread, 0 mismatches on every run. 2. AutoRefreshThenFilter_ConcurrentPropertyMutationsOnAddedItems_AllFinalStatesObserved End-to-end: SourceCache + AutoRefresh(IsActive) + Filter(IsActive). Cache pre-populated, then four worker threads concurrently set Activated on every item to a per-item randomized final value. Multiple threads writing the same final value generate many concurrent PropertyChanged invocations per item, exercising SinglePropertySubscription's DeliveryQueue under contention. After the storm the filter contents must match the per-item finalActive map. Deliberately not testing 'mutate while adding' against AutoRefresh: ObservableCache.CreateConnectObservable has the same initial.Concat(_changes) TOCTOU subscribe-window bug as the WhenPropertyChanged shape this PR fixes, and a during-add test would detect that separate cache-side bug as noise unrelated to this PR. Local: 150/150 Binding tests pass; new tests 10/10 stable. * Strengthen deep-chain torture invariants Last-emission-equals-current proves the drainer reached the end of the queue without corruption, but doesn't catch garbage values or Rx contract violations along the way. Add three additional invariants per iteration: 1. ValidateSynchronization() on the subscription chain. Any concurrent OnNext to the user observer (which would indicate a SharedDeliveryQueue serialization bug) throws UnsynchronizedNotificationException during the test instead of silently producing wrong data. 2. Build the set of values any thread could legitimately have written (initial leaf, the leaf-int range, and each subtree-swap range), then assert every emission is in that set. Catches torn reads or stale-detached-subtree mis-reads. 3. First emission must equal the initial value when notifyInitial=true. Catches initial-emit-dropped bugs that the final-state check could mask if the final state happens to equal the initial. What this test still does NOT verify: that every mutation which landed on the live chain produced an emission. That requires causal-history reconstruction which isn't tractable from outside the operator. Local 10/10 stable, ~325ms per run. * Use AsAggregator in the AutoRefresh integration test Replace the manual HashSet + Subscribe(changes => switch on Reason / Add / Remove) plumbing with .AsAggregator(). The aggregator provides Data (IObservableCache) for current contents and Error for terminal exception state, both thread-safe to read. Net effect: ~25 lines of manual change tracking collapse to one line plus assertions against results.Data.Keys. * Remove dedup; route PropertyChanged events without equality guard Drops the one-shot equality dedup in the Emitter and removes the Emitter class entirely. SinglePropertySubscription and DeepChainSubscription now forward every emission through their DeliveryQueue / DeliverySubQueue directly. Same-valued PropertyChanged events that follow the initial emission are delivered as legitimate events; nothing in the property pipeline drops events for equality reasons. Other fixes in the same pass: - TryOnError helpers wrap both EmitCurrent and ProcessSignal so a downstream observer that throws from OnError cannot propagate the secondary exception back into the PropertyChanged setter (shallow) or the SharedDeliveryQueue drainer (deep). - DeepChainSubscription pre-allocates one notifier callback per level in the constructor; ResubscribeFrom indexes into _levelCallbacks instead of allocating a fresh closure per re-walk. - Renamed the existing notifyInitial=false dedup test to PropertyChangedEventsAreNeverDropped_RegardlessOfNotifyInitial and extended it to also cover notifyInitial=true on shallow and deep chains. - Class summary, in-test commentary, and production rationales rewritten to present-tense contracts; removed migration narrative, PR references, and past-bug descriptors per repo comment instructions. 150/150 Binding tests pass; race fixture 10/10 stable. * Address Jake's PR feedback: simplify race tests, split single-threaded tests, let observer throws propagate Production: - EmitCurrent (SinglePropertySubscription) and ProcessSignal (DeepChainSubscription) no longer wrap the downstream OnNext in try/catch. Per the Rx contract, if the user observer throws, the exception propagates back to whoever invoked the PropertyChanged setter (shallow) or back through the SharedDeliveryQueue drainer (deep), matching what a plain Subject would do. The try/catch around the chain walk and accessor stays - those are user code whose throws route to OnError. - TryOnError helpers removed; their swallow-secondary-throw behaviour was non-standard. Tests: - Split WhenPropertyChangedRaceFixture into two fixtures. RaceFixture now contains only the truly multi-threaded tests (5 tests: shallow concurrent mutation during initial emit, deep concurrent leaf mutation during initial emit, deep concurrent parent swap, deep 5-level torture, AutoRefresh integration). The single-threaded contract tests move to a new WhenPropertyChangedBehaviorFixture (7 tests: handler-attach ordering, four no-dedup scenarios split into individual [Fact]s, deep post-swap leaf capture, deep mid-chain swap re-targeting). - The two concurrent initial-emit tests adopt Jake's symmetric Task.WhenAll(subscribe, mutate) shape: observer's OnNext signals + waits, mutator waits then mutates and releases. Removes the manual try/finally + subscribeTask.Result + WaitForCondition plumbing. 153/153 Binding tests pass; the property-changed fixtures run 10/10 stable. * Adopt Jake's exact implementation for Shallow_ConcurrentMutationDuringInitialEmit_NotDropped Replaces the existing test body with Jake's verbatim code from the PR review: named-argument style with column-aligned colons, Item class with Id and Value, observedValues / propertyValue naming, BeEquivalentTo with WithStrictOrdering and the original because string. Removes TestModel from the race fixture (no longer used). * Drop cache-side TOCTOU rationale from integration test comment The cache-side observation is unrelated to this PR. The integration test pre-populates the cache to keep what's being verified focused on the WhenPropertyChanged path under multi-threaded property contention; that's what the comment should say. * Skip AutoRefresh+Filter integration tests; add dual-subscriber variant Both AutoRefresh+Filter integration variants reproduce a race that lives in AutoRefresh's internal Publish multicast: the Filter path reads the property value before MergeMany subscribes the per-item refresh handler, so a concurrent property mutation in that gap is dropped. AutoRefresh calls WhenPropertyChanged with notifyInitial=false, so the per-item subscribe is not the source of the race. Both tests fail equally on upstream main and on this branch; mark them [Fact(Skip)] so the scenarios are preserved without breaking the build, and track the AutoRefresh fix separately. Also: KeyedActivable now only raises PropertyChanged on actual value change (standard MVVM semantics), so a dropped transition is unrecoverable, matching real consumer patterns. (cherry picked from commit 5f44d05706963fe673440944ddb1cb3d99802bd0) --- .../WhenPropertyChangedBehaviorFixture.cs | 275 ++++++++++ .../Binding/WhenPropertyChangedRaceFixture.cs | 474 ++++++++++++++++++ .../Binding/ObservablePropertyFactory.cs | 276 ++++++++-- 3 files changed, 981 insertions(+), 44 deletions(-) create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs create mode 100644 src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs new file mode 100644 index 000000000..48a60e35d --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedBehaviorFixture.cs @@ -0,0 +1,275 @@ +// Copyright (c) 2011-2025 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; +using System.Collections.Generic; +using System.ComponentModel; + +using DynamicData.Binding; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Binding; + +/// +/// Single-threaded contract tests for : +/// handler attachment ordering, no-dedup semantics, deep-chain re-walks on swaps. +/// +public sealed class WhenPropertyChangedBehaviorFixture +{ + [Fact] + public void Shallow_NotifyInitialFalse_SubscribesHandlerBeforeReturning() + { + // notifyOnInitialValue=false: Subscribe must return only after the PropertyChanged handler + // is attached. A setter that fires immediately after Subscribe returns must reach the + // observer. + var model = new TestModel { Value = 10 }; + var emissions = new List(); + + using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false) + .Subscribe(pv => emissions.Add(pv.Value)); + + model.Value = 20; + + emissions.Should().Equal(new[] { 20 }); + } + + [Fact] + public void Shallow_NotifyInitialTrue_DoesNotDedupSameValuedEvents() + { + var model = new TestModel { Value = 10 }; + var emissions = new List(); + + using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: true) + .Subscribe(pv => emissions.Add(pv.Value)); + + model.Value = 10; + model.Value = 10; + model.Value = 10; + + emissions.Should().Equal(new[] { 10, 10, 10, 10 }); + } + + [Fact] + public void Shallow_NotifyInitialFalse_DoesNotDedupSameValuedEvents() + { + var model = new TestModel { Value = 10 }; + var emissions = new List(); + + using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false) + .Subscribe(pv => emissions.Add(pv.Value)); + + model.Value = 42; + model.Value = 42; + + emissions.Should().Equal(new[] { 42, 42 }); + } + + [Fact] + public void DeepChain_NotifyInitialTrue_DoesNotDedupSameValuedEvents() + { + var parent = new ParentModel { Child = new ChildModel { Age = 1 } }; + var emissions = new List(); + + using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true) + .Subscribe(pv => emissions.Add(pv.Value)); + + parent.Child!.Age = 1; + parent.Child!.Age = 1; + parent.Child!.Age = 1; + + emissions.Should().Equal(new[] { 1, 1, 1, 1 }); + } + + [Fact] + public void DeepChain_NotifyInitialFalse_DoesNotDedupSameValuedEvents() + { + var parent = new ParentModel { Child = new ChildModel { Age = 1 } }; + var emissions = new List(); + + using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: false) + .Subscribe(pv => emissions.Add(pv.Value)); + + parent.Child!.Age = 7; + parent.Child!.Age = 7; + + emissions.Should().Equal(new[] { 7, 7 }); + } + + [Fact] + public void DeepChain_PostSwap_LeafEventOnNewChild_Captured() + { + // After parent.Child is reassigned, the leaf-level subscription must be re-attached + // against the new child. A subsequent leaf mutation on the new child must be captured. + var parent = new ParentModel { Child = new ChildModel { Age = 10 } }; + var emissions = new List(); + + using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true) + .Subscribe(pv => emissions.Add(pv.Value)); + + var newChild = new ChildModel { Age = 20 }; + parent.Child = newChild; + newChild.Age = 30; + + emissions.Should().Equal(new[] { 10, 20, 30 }); + } + + [Fact] + public void DeepChain_MidChainSwap_DeeperLevelsRetargetCorrectly() + { + // Mid-chain swap on a 4-level chain. When level 3 is reassigned, the leaf subscription + // must re-attach against the new subtree; events on the old subtree must be ignored + // (its notifier subscription was disposed). + var l1 = new Level1 + { + Child = new Level2 + { + Child = new Level3 + { + Child = new Level4 { Leaf = 10 }, + }, + }, + }; + + var emissions = new List(); + using var sub = l1.WhenPropertyChanged(x => x.Child!.Child!.Child!.Leaf, notifyOnInitialValue: true) + .Subscribe(pv => emissions.Add(pv.Value)); + + emissions.Should().Equal(new[] { 10 }, "initial emission"); + + var originalLeaf = l1.Child!.Child!.Child!; + + var newL4 = new Level4 { Leaf = 20 }; + l1.Child!.Child!.Child = newL4; + + emissions.Should().Equal(new[] { 10, 20 }, "mid-chain swap emits the new leaf value"); + + newL4.Leaf = 30; + emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on new subtree is captured"); + + originalLeaf.Leaf = 999; + emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on detached subtree is ignored"); + } + + private sealed class TestModel : INotifyPropertyChanged + { + private int _value; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Value + { + get => _value; + set + { + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + } + + private sealed class ParentModel : INotifyPropertyChanged + { + private ChildModel? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public ChildModel? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class ChildModel : INotifyPropertyChanged + { + private int _age; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Age + { + get => _age; + set + { + _age = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age))); + } + } + } + + private sealed class Level1 : INotifyPropertyChanged + { + private Level2? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Level2? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Level2 : INotifyPropertyChanged + { + private Level3? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Level3? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Level3 : INotifyPropertyChanged + { + private Level4? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Level4? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Level4 : INotifyPropertyChanged + { + private int _leaf; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Leaf + { + get => _leaf; + set + { + _leaf = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf))); + } + } + } +} diff --git a/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs b/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs new file mode 100644 index 000000000..9da900efe --- /dev/null +++ b/src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs @@ -0,0 +1,474 @@ +// Copyright (c) 2011-2025 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; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; + +using DynamicData.Binding; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Binding; + +/// +/// Multi-threaded race tests for . +/// Each test forces concurrency between the operator's subscribe call (or chain re-walk) and one or more +/// notifiers firing on other threads. +/// +public sealed class WhenPropertyChangedRaceFixture +{ + private static readonly TimeSpan ConditionTimeout = TimeSpan.FromSeconds(30); + + [Fact] + public async Task DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped() + { + // Two threads concurrently swap parent.Child. After both swaps complete, a leaf mutation + // on the current child must be captured. SharedDeliveryQueue serialises the level-0 + // signals on the drainer, so the final level-1 subscription always targets parent.Child's + // current value. + const int iterations = 50; + var losses = 0; + + for (var iter = 0; iter < iterations; iter++) + { + var parent = new ParentModel { Child = new ChildModel { Age = 0 } }; + var emissions = new List(); + + using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: false) + .Subscribe(pv => { lock (emissions) emissions.Add(pv.Value); }); + + var newChild1 = new ChildModel { Age = 1 }; + var newChild2 = new ChildModel { Age = 2 }; + + using var barrier = new Barrier(2); + var taskA = Task.Run(() => { barrier.SignalAndWait(); parent.Child = newChild1; }); + var taskB = Task.Run(() => { barrier.SignalAndWait(); parent.Child = newChild2; }); + await Task.WhenAll(taskA, taskB).WaitAsync(ConditionTimeout); + + var winner = parent.Child; + if (winner is null) + { + continue; + } + + winner.Age = 99; + + WaitForCondition(() => { lock (emissions) return emissions.Contains(99); }); + + lock (emissions) + { + if (!emissions.Contains(99)) + { + losses++; + } + } + } + + losses.Should().Be(0, $"out of {iterations} iterations, {losses} dropped the leaf event on the post-swap winner"); + } + + [Fact] + public async Task DeepChain_FiveLevels_AllLevelsMutatedConcurrently_FinalEmissionMatchesActual() + { + // Torture: five worker threads each mutating at a different level of a 5-level chain. + // Mutations that land on detached subtrees are ignored (their notifier subscriptions were + // disposed by ResubscribeFrom). Mutations on the live chain reach the drainer. + // + // Three invariants per iteration: + // (a) Rx contract: ValidateSynchronization catches any concurrent OnNext on the user + // observer (a SharedDeliveryQueue serialisation failure). + // (b) Value legality: every emission must be a value that some thread legitimately + // wrote. + // (c) Final consistency: after Task.WhenAll the drainer continues until the queue is + // empty. The last processed signal triggers a ReadCurrent against the now-frozen + // chain state, so emissions.Last() == ReadCurrent(). + const int iterations = 50; + const int mutationsPerThread = 200; + var mismatches = 0; + + for (var iter = 0; iter < iterations; iter++) + { + var root = NewDeepChain(0); + var emissions = new List(); + + using var sub = root.WhenPropertyChanged(r => r.Child!.Child!.Child!.Child!.Leaf, notifyOnInitialValue: true) + .ValidateSynchronization() + .Subscribe(pv => { lock (emissions) emissions.Add(pv.Value); }); + + using var barrier = new Barrier(5); + var iterSeed = iter * 10_000; + var tasks = new[] + { + Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < mutationsPerThread; i++) + { + root.Child = NewDeep2(iterSeed + 40_000 + i); + } + }), + Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < mutationsPerThread; i++) + { + var l2 = root.Child; + if (l2 is not null) l2.Child = NewDeep3(iterSeed + 30_000 + i); + } + }), + Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < mutationsPerThread; i++) + { + var l3 = root.Child?.Child; + if (l3 is not null) l3.Child = NewDeep4(iterSeed + 20_000 + i); + } + }), + Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < mutationsPerThread; i++) + { + var l4 = root.Child?.Child?.Child; + if (l4 is not null) l4.Child = new Deep5 { Leaf = iterSeed + 10_000 + i }; + } + }), + Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < mutationsPerThread; i++) + { + var l5 = root.Child?.Child?.Child?.Child; + if (l5 is not null) l5.Leaf = i; + } + }), + }; + + await Task.WhenAll(tasks).WaitAsync(ConditionTimeout); + + var actualFinal = root.Child!.Child!.Child!.Child!.Leaf; + + WaitForCondition(() => { lock (emissions) return emissions.Count > 0 && emissions[^1] == actualFinal; }); + + var legal = new HashSet { 0 }; + for (var i = 0; i < mutationsPerThread; i++) + { + legal.Add(i); + legal.Add(iterSeed + 10_000 + i); + legal.Add(iterSeed + 20_000 + i); + legal.Add(iterSeed + 30_000 + i); + legal.Add(iterSeed + 40_000 + i); + } + + lock (emissions) + { + emissions.Should().NotBeEmpty($"iter {iter}: notifyOnInitialValue=true requires at least the initial emission"); + emissions[0].Should().Be(0, $"iter {iter}: first emission must be the initial value"); + + var illegal = emissions.Where(v => !legal.Contains(v)).ToList(); + illegal.Should().BeEmpty($"iter {iter}: every emission must be a value some thread wrote; saw {string.Join(",", illegal.Take(5))}"); + + if (emissions.Count == 0 || emissions[^1] != actualFinal) + { + mismatches++; + } + } + } + + mismatches.Should().Be(0, $"out of {iterations} iterations, {mismatches} ended with the last emission not matching the actual final chain leaf"); + } + + [Fact(Skip = "AutoRefresh has a separate concurrency bug; tracked separately")] + public async Task AutoRefreshThenFilter_ConcurrentAddsAndPropertyActivation_AllItemsObserved() + { + // One adder thread sequentially adds items to the cache while a single flipper thread + // concurrently sets each item's Activated to true. Final filter contents must include + // every item (every item ends Activated=true). + // + // KeyedActivable's setter only raises PropertyChanged on actual value change, so a + // dropped false->true transition is unrecoverable. + // + // The race lives in AutoRefresh's internal Publish multicast: Sub 1 (Filter path) + // receives the Add and reads the property before Sub 2 (MergeMany) subscribes the + // per-item refresh handler. A concurrent flip landing in that gap is dropped. This + // is not a WhenPropertyChanged issue: AutoRefresh calls WhenPropertyChanged with + // notifyInitial=false, so the per-item subscribe attaches the handler immediately + // and has no internal race window. + const int iterations = 100; + const int itemCount = 200; + + for (var iter = 0; iter < iterations; iter++) + { + using var cache = new SourceCache(x => x.Id); + var items = Enumerable.Range(0, itemCount).Select(i => new KeyedActivable(i)).ToList(); + + using var results = cache.Connect() + .AutoRefresh(x => x.Activated) + .Filter(x => x.Activated) + .AsAggregator(); + + using var barrier = new Barrier(2); + + var adder = Task.Run(() => + { + barrier.SignalAndWait(); + foreach (var item in items) cache.AddOrUpdate(item); + }); + + var flipper = Task.Run(() => + { + barrier.SignalAndWait(); + foreach (var item in items) item.Activated = true; + }); + + await Task.WhenAll(adder, flipper).WaitAsync(ConditionTimeout); + + var expected = items.Select(x => x.Id).ToHashSet(); + WaitForCondition(() => results.Data.Keys.ToHashSet().SetEquals(expected)); + + var actual = results.Data.Keys.ToHashSet(); + actual.Should().BeEquivalentTo(expected, $"iter {iter}: every item ends Activated=true and must appear in the filter (missing: {string.Join(",", expected.Except(actual))})"); + results.Error.Should().BeNull($"iter {iter}: pipeline must not error"); + } + } + + [Fact(Skip = "AutoRefresh has a separate concurrency bug; tracked separately")] + public async Task AutoRefreshThenFilter_DualSubscribers_AllItemsObserved() + { + // Two independent cache subscribers running on the ThreadPool: + // Sub 1 (mutator): on every Add change, flips item.Activated to true + // Sub 2 (filter chain): AutoRefresh + Filter (filter = Activated) + // Items start with Activated=false (filtered out). The mutator flips every item, so + // the final filter contents must include every item. + // + // Same root cause as the single-flipper variant above: AutoRefresh's internal Publish + // multicasts the Add to the Filter path before MergeMany subscribes the per-item + // refresh handler. The mutator's flip can land in that gap and be dropped. + const int iterations = 100; + const int itemCount = 200; + + for (var iter = 0; iter < iterations; iter++) + { + using var cache = new SourceCache(x => x.Id); + var items = Enumerable.Range(0, itemCount).Select(i => new KeyedActivable(i)).ToList(); + + using var mutator = cache.Connect() + .ObserveOn(TaskPoolScheduler.Default) + .Subscribe(changes => + { + foreach (var change in changes) + { + if (change.Reason == ChangeReason.Add) + { + change.Current.Activated = true; + } + } + }); + + using var results = cache.Connect() + .ObserveOn(TaskPoolScheduler.Default) + .AutoRefresh(x => x.Activated) + .Filter(x => x.Activated) + .AsAggregator(); + + foreach (var item in items) cache.AddOrUpdate(item); + + var expected = items.Select(x => x.Id).ToHashSet(); + WaitForCondition(() => results.Data.Keys.ToHashSet().SetEquals(expected)); + + var actual = results.Data.Keys.ToHashSet(); + actual.Should().BeEquivalentTo(expected, $"iter {iter}: every item was flipped to Activated=true by the mutator and must appear in the filter (missing: {string.Join(",", expected.Except(actual))})"); + results.Error.Should().BeNull($"iter {iter}: pipeline must not error"); + } + } + + private static Deep1 NewDeepChain(int leaf) => + new Deep1 { Child = NewDeep2(leaf) }; + + private static Deep2 NewDeep2(int leaf) => + new Deep2 { Child = NewDeep3(leaf) }; + + private static Deep3 NewDeep3(int leaf) => + new Deep3 { Child = NewDeep4(leaf) }; + + private static Deep4 NewDeep4(int leaf) => + new Deep4 { Child = new Deep5 { Leaf = leaf } }; + + private static void WaitForCondition(Func condition, TimeSpan? timeout = null) => + SpinWait.SpinUntil(condition, timeout ?? ConditionTimeout); + + private sealed class Item : INotifyPropertyChanged + { + private int _value; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Id { get; init; } + + public int Value + { + get => _value; + set + { + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + } + + private sealed class ParentModel : INotifyPropertyChanged + { + private ChildModel? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public ChildModel? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class ChildModel : INotifyPropertyChanged + { + private int _age; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Age + { + get => _age; + set + { + _age = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age))); + } + } + } + + private sealed class Deep1 : INotifyPropertyChanged + { + private Deep2? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Deep2? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Deep2 : INotifyPropertyChanged + { + private Deep3? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Deep3? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Deep3 : INotifyPropertyChanged + { + private Deep4? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Deep4? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Deep4 : INotifyPropertyChanged + { + private Deep5? _child; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Deep5? Child + { + get => _child; + set + { + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } + } + + private sealed class Deep5 : INotifyPropertyChanged + { + private int _leaf; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Leaf + { + get => _leaf; + set + { + _leaf = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf))); + } + } + } + + private sealed class KeyedActivable : INotifyPropertyChanged + { + private bool _activated; + + public KeyedActivable(int id) + { + Id = id; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public int Id { get; } + + public bool Activated + { + get => _activated; + set + { + if (_activated == value) return; + _activated = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Activated))); + } + } + } +} diff --git a/src/DynamicData/Binding/ObservablePropertyFactory.cs b/src/DynamicData/Binding/ObservablePropertyFactory.cs index d9f9be00c..1c1416f4f 100644 --- a/src/DynamicData/Binding/ObservablePropertyFactory.cs +++ b/src/DynamicData/Binding/ObservablePropertyFactory.cs @@ -1,12 +1,16 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 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.Collections.Generic; using System.ComponentModel; using System.Linq.Expressions; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData.Internal; + namespace DynamicData.Binding; internal sealed class ObservablePropertyFactory @@ -14,74 +18,258 @@ internal sealed class ObservablePropertyFactory { private readonly Func>> _factory; - public ObservablePropertyFactory(Func valueAccessor, ObservablePropertyPart[] chain) => - _factory = (t, notifyInitial) => + public ObservablePropertyFactory(Func valueAccessor, ObservablePropertyPart[] chain) + { + // chain is leaf-first (output of SplitIntoSteps). Reverse once to root-to-leaf order. + var rootToLeaf = chain.AsEnumerable().Reverse().ToArray(); + _factory = (source, notifyInitial) => Observable.Create>( + observer => new DeepChainSubscription(observer, source, rootToLeaf, valueAccessor, notifyInitial)); + } + + public ObservablePropertyFactory(Expression> expression) + { + // Shallow form: single property, no chain. Used when depth == 1. Skips SharedDeliveryQueue + // and Observable.FromEventPattern in favour of a direct PropertyChanged += handler for + // the high-frequency single-property hot path. + var memberName = expression.GetProperty().Name; + var accessor = expression.Compile(); + _factory = (source, notifyInitial) => Observable.Create>( + observer => new SinglePropertySubscription(observer, source, memberName, accessor, notifyInitial)); + } + + public IObservable> Create(TObject source, bool notifyInitial) => _factory(source, notifyInitial); + + // Single-property subscription. Attaches a direct PropertyChanged handler and synchronizes events. Used for + // x => x.Prop (depth == 1) where Observable.FromEventPattern would be needless overhead on the hot path. + // + // notifyInitial only controls whether the constructor synthesises an initial emission. There + // is no equality dedup at the subscribe seam: a same-valued PropertyChanged firing in the + // subscribe window is a legitimate event and must be delivered. The "never drop events" + // contract takes precedence over avoiding a benign duplicate. + private sealed class SinglePropertySubscription : IDisposable + { + private readonly TObject _source; + private readonly string _memberName; + private readonly Func _accessor; + private readonly IObserver> _observer; + #if NET9_0_OR_GREATER + private readonly Lock _notificationGate; + #else + private readonly object _notificationGate; + #endif + + public SinglePropertySubscription( + IObserver> observer, + TObject source, + string memberName, + Func accessor, + bool notifyInitial) { - // 1) notify when values have changed - // 2) resubscribe when changed because it may be a child object which has changed - var valueHasChanged = GetNotifiers(t, chain).Merge().Take(1).Repeat(); + _source = source; + _memberName = memberName; + _accessor = accessor; + _observer = observer; + _notificationGate = new(); + + // Attach PropertyChanged handler FIRST so events during the initial read are not missed. + _source.PropertyChanged += OnPropertyChanged; + if (notifyInitial) { - valueHasChanged = Observable.Defer(() => Observable.Return(Unit.Default)).Concat(valueHasChanged); + EmitCurrent(); } + } - return valueHasChanged.Select(_ => GetPropertyValue(t, chain, valueAccessor)); - }; + public void Dispose() + { + _source.PropertyChanged -= OnPropertyChanged; + } - public ObservablePropertyFactory(Expression> expression) + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName == _memberName) + { + EmitCurrent(); + } + } + + // Reads the current property value and forwards it through the queue. The accessor is + // user code and may throw; that exception routes to OnError. The downstream OnNext call + // is NOT wrapped: per the Rx contract, if the user observer throws, the exception + // propagates back to whoever invoked the PropertyChanged setter, matching what a plain + // Subject.OnNext would do. + private void EmitCurrent() + { + lock (_notificationGate) + { + PropertyValue value; + try + { + value = new PropertyValue(_source, _accessor(_source)); + } + catch (Exception ex) + { + _observer.OnError(ex); + return; + } + + _observer.OnNext(value); + } + } + } + + // Deep-chain subscription. + // + // notifyInitial only controls whether ProcessSignal emits the current chain value during + // the InitialSetupSignal pass. There is no equality dedup at the subscribe seam: every + // chain event is delivered. + private sealed class DeepChainSubscription : IDisposable { - // this overload is used for shallow observations i.e. depth = 1, so no need for re-subscriptions - var member = expression.GetProperty(); - var accessor = expression.Compile(); + // Sentinel signal value enqueued during subscribe to perform the initial chain setup. + private const int InitialSetupSignal = -1; + + private readonly TObject _source; + private readonly ObservablePropertyPart[] _rootToLeaf; + private readonly Func _valueAccessor; + private readonly bool _notifyInitial; + private readonly IObserver> _observer; + #if NET9_0_OR_GREATER + private readonly Lock _notificationGate; + #else + private readonly object _notificationGate; + #endif + private readonly SerialDisposable[] _levelSlots; + + // Pre-allocated per-level notifier callbacks. Indexed by level. ResubscribeFrom reuses + // these instead of allocating a fresh closure per re-walk. + private readonly Action[] _levelCallbacks; - _factory = (t, notifyInitial) => + public DeepChainSubscription( + IObserver> observer, + TObject source, + ObservablePropertyPart[] rootToLeaf, + Func valueAccessor, + bool notifyInitial) { - PropertyValue Factory() => new(t, accessor(t)); + _source = source; + _rootToLeaf = rootToLeaf; + _valueAccessor = valueAccessor; + _notifyInitial = notifyInitial; + _observer = observer; + _notificationGate = new(); - var propertyChanged = Observable.FromEventPattern(handler => t.PropertyChanged += handler, handler => t.PropertyChanged -= handler).Where(args => args.EventArgs.PropertyName == member.Name).Select(_ => Factory()); + var depth = rootToLeaf.Length; + _levelSlots = new SerialDisposable[depth]; + _levelCallbacks = new Action[depth]; + for (var i = 0; i < depth; i++) + { + _levelSlots[i] = new SerialDisposable(); + var level = i; + _levelCallbacks[i] = _ => ProcessChange(level); + } + + // Kick off initial chain setup via the drainer. The subscribe thread becomes the + // drainer (no one else is draining yet on a fresh subscription) and runs + // ProcessSignal(InitialSetupSignal) synchronously, which attaches the chain and + // emits the initial value. + ProcessChange(InitialSetupSignal); + } - if (!notifyInitial) + public void Dispose() + { + foreach (var slot in _levelSlots) { - return propertyChanged; + slot.Dispose(); } + } - var initial = Observable.Defer(() => Observable.Return(Factory())); - return initial.Concat(propertyChanged); - }; - } + private void ProcessChange(int level) + { + lock (_notificationGate) + { + // The chain walk (Invoker / notifier Factory / ReadCurrent's accessor) is user code and may throw; + // those exceptions route to OnError. The downstream OnNext call is NOT wrapped: per the Rx contract, + // if the user observer throws, the exception propagates back through the drainer, matching what a plain + // Subject would do. + // + // The two cases (initial setup vs level-fire) collapse to: + // startLevel = (initial) ? 0 : level + 1 + // emit = (level-fire) || _notifyInitial + var isInitial = level == InitialSetupSignal; + var shouldEmit = !isInitial || _notifyInitial; + PropertyValue value; + try + { + ResubscribeFrom(isInitial ? 0 : level + 1); + if (!shouldEmit) + { + return; + } - public IObservable> Create(TObject source, bool notifyInitial) => _factory(source, notifyInitial); + value = ReadCurrent(); + } + catch (Exception ex) + { + _observer.OnError(ex); + return; + } - // create notifier for all parts of the property path - private static IEnumerable> GetNotifiers(TObject source, IEnumerable chain) - { - object? value = source; - foreach (var metadata in chain.Reverse()) + _observer.OnNext(value); + } + } + + private void ResubscribeFrom(int startLevel) { - var obs = metadata.Factory(value).Publish().RefCount(); - value = metadata.Invoker(value); - yield return obs; + var depth = _rootToLeaf.Length; + if (startLevel >= depth) + { + return; + } - if (value is null) + object? value = _source; + for (var i = 0; i < startLevel; i++) { - yield break; + value = _rootToLeaf[i].Invoker(value); + if (value is null) + { + for (var j = startLevel; j < depth; j++) + { + _levelSlots[j].Disposable = Disposable.Empty; + } + + return; + } + } + + for (var i = startLevel; i < depth; i++) + { + if (value is null) + { + _levelSlots[i].Disposable = Disposable.Empty; + continue; + } + + var notifier = _rootToLeaf[i].Factory(value); + _levelSlots[i].Disposable = notifier.Subscribe(_levelCallbacks[i]); + + value = _rootToLeaf[i].Invoker(value); } } - } - // walk the tree and break at a null, or return the value [should reduce this to a null an expression] - private static PropertyValue GetPropertyValue(TObject source, IEnumerable chain, Func valueAccessor) - { - object? value = source; - foreach (var metadata in chain.Reverse()) + // Root-to-leaf chain walk. Stops at null and returns an unobtainable PropertyValue. + private PropertyValue ReadCurrent() { - value = metadata.Invoker(value); - if (value is null) + object? value = _source; + foreach (var metadata in _rootToLeaf) { - return new PropertyValue(source); + value = metadata.Invoker(value); + if (value is null) + { + return new PropertyValue(_source); + } } - } - return new PropertyValue(source, valueAccessor(source)); + return new PropertyValue(_source, _valueAccessor(_source)); + } } } From 41ee2ec6bcbc2be08a1b3db96f873549aec3dc6c Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Tue, 16 Jun 2026 20:27:20 -0700 Subject: [PATCH 07/14] Fix FilterImmutable Update->Remove to carry the previous value as Current (#1113) When an Update changeset entry has Previous matching the predicate and Current not matching, FilterImmutable emits a Remove. Previously this Remove carried the new (non-matching) value as Current, violating the Change contract that Remove.Current is the item being removed (the item that just left downstream). Since the new value never reached downstream, only the previous value can satisfy this contract. Consumers that read Current on Remove (e.g. composition with TransformImmutable, side-effect handlers like DisposeMany or OnItemRemoved equivalents) received the wrong reference, silently producing incorrect results or InvalidCastException. (cherry picked from commit 6d2144c32d035c31c81636bb57c893f3c809fa1c) --- .../Cache/FilterImmutableFixture.cs | 159 +++++++++++------- .../Cache/Internal/FilterImmutable.cs | 8 +- 2 files changed, 108 insertions(+), 59 deletions(-) diff --git a/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs b/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs index a86e7438f..5abf88761 100644 --- a/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs +++ b/src/DynamicData.Tests/Cache/FilterImmutableFixture.cs @@ -4,6 +4,8 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using DynamicData.Tests.Utilities; + using FluentAssertions; using Xunit; @@ -16,9 +18,10 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded( { using var source = new Subject>(); - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); // Add items @@ -31,8 +34,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded( }); results.Error.Should().BeNull(); - results.Messages.Count.Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "2 items were added, with 1 excluded"); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "2 items were added, with 1 excluded"); // Replace items, changing inclusion @@ -45,8 +48,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded( }); results.Error.Should().BeNull(); - results.Messages.Skip(1).Count().Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item4 }, "2 items were replaced, with 1 excluded"); + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item4 }, "2 items were replaced, with 1 excluded"); // Replace items, not changing inclusion @@ -59,8 +62,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded( }); results.Error.Should().BeNull(); - results.Messages.Skip(2).Count().Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item6 }, "2 items were replaced, with 1 excluded"); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item6 }, "2 items were replaced, with 1 excluded"); // Refresh items @@ -71,8 +74,8 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded( }); results.Error.Should().BeNull(); - results.Messages.Skip(3).Count().Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item6 }, "2 items were refreshed, with 1 excluded"); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item6 }, "2 items were refreshed, with 1 excluded"); // Remove items @@ -83,18 +86,18 @@ public void ItemsAreManipulated_UnmatchedItemsAreExcludedAndIndexesAreDiscarded( }); results.Error.Should().BeNull(); - results.Messages.Skip(4).Count().Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEmpty("2 items were removed, with one excluded"); + results.RecordedChangeSets.Skip(4).Count().Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Should().BeEmpty("2 items were removed, with one excluded"); - results.Messages.SelectMany(static changes => changes).Should().AllSatisfy( + results.RecordedChangeSets.SelectMany(static changes => changes).Should().AllSatisfy( change => { change.CurrentIndex.Should().Be(-1); change.PreviousIndex.Should().Be(-1); }, because: "indexes should not be preserved"); - results.IsCompleted.Should().BeFalse(); + results.HasCompleted.Should().BeFalse(); } [Fact] @@ -102,9 +105,10 @@ public void ItemsAreMoved_ChangesAreNotPropagated() { using var source = new Subject>(); - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); // Initial setup var item1 = new Item() { Id = 1, IsIncluded = true }; @@ -116,18 +120,18 @@ public void ItemsAreMoved_ChangesAreNotPropagated() new(reason: ChangeReason.Add, key: item2.Id, current: item2, index: 1), new(reason: ChangeReason.Add, key: item3.Id, current: item3, index: 2) }); - results.Messages.Clear(); + var changeSetsBeforeMove = results.RecordedChangeSets.Count; // Move items source.OnNext(new ChangeSet() { new(reason: ChangeReason.Moved, key: item1.Id, current: item1, previous: default, currentIndex: 2, previousIndex: 0), - new(reason: ChangeReason.Moved, key: item2.Id, current: item1, previous: default, currentIndex: 0, previousIndex: 1) + new(reason: ChangeReason.Moved, key: item2.Id, current: item2, previous: default, currentIndex: 0, previousIndex: 1) }); results.Error.Should().BeNull(); - results.Messages.Should().BeEmpty("move operations should not be propagated"); + results.RecordedChangeSets.Skip(changeSetsBeforeMove).Should().BeEmpty("move operations should not be propagated"); } [Fact] @@ -144,9 +148,10 @@ public void PredicateThrows_ExceptionIsCaptured() var error = new Exception(); - using var results = source + using var subscription = source .FilterImmutable(predicate: _ => throw error) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); var item1 = new Item() { Id = 1, IsIncluded = true }; @@ -156,8 +161,8 @@ public void PredicateThrows_ExceptionIsCaptured() }); results.Error.Should().Be(error); - results.Messages.Should().BeEmpty("no source operations should have been processed"); - results.IsCompleted.Should().BeFalse(); + results.RecordedChangeSets.Should().BeEmpty("no source operations should have been processed"); + results.HasCompleted.Should().BeFalse(); } [Fact] @@ -165,9 +170,10 @@ public void SourceCompletes_CompletionIsPropagated() { using var source = new Subject>(); - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); var item1 = new Item() { Id = 1, IsIncluded = true }; @@ -178,11 +184,11 @@ public void SourceCompletes_CompletionIsPropagated() source.OnCompleted(); results.Error.Should().BeNull(); - results.IsCompleted.Should().BeTrue(); - results.Messages.Count.Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); + results.HasCompleted.Should().BeTrue(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); + - // Make sure no extraneous notifications are published. var item2 = new Item() { Id = 2, IsIncluded = true }; source.OnNext(new ChangeSet() @@ -190,7 +196,7 @@ public void SourceCompletes_CompletionIsPropagated() new(reason: ChangeReason.Add, key: item2.Id, current: item2) }); - results.Messages.Skip(1).Should().BeEmpty("no source operations should have been processed"); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations should have been processed"); } [Fact] @@ -210,17 +216,16 @@ public void SourceCompletesImmediately_CompletionIsPropagated() return Disposable.Empty; }); - var error = new Exception(); - - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); results.Error.Should().BeNull(); - results.IsCompleted.Should().BeTrue(); - results.Messages.Count.Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); + results.HasCompleted.Should().BeTrue(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); } [Fact] @@ -230,9 +235,10 @@ public void SourceErrors_ErrorIsPropagated() var error = new Exception(); - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); var item1 = new Item() { Id = 1, IsIncluded = true }; @@ -243,11 +249,11 @@ public void SourceErrors_ErrorIsPropagated() source.OnError(error); results.Error.Should().Be(error); - results.IsCompleted.Should().BeFalse(); - results.Messages.Count.Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); + results.HasCompleted.Should().BeFalse(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); + - // Make sure no extraneous notifications are published. var item2 = new Item() { Id = 2, IsIncluded = true }; source.OnNext(new ChangeSet() @@ -255,7 +261,7 @@ public void SourceErrors_ErrorIsPropagated() new(reason: ChangeReason.Add, key: item2.Id, current: item2) }); - results.Messages.Skip(1).Should().BeEmpty("no source operations should have been processed"); + results.RecordedChangeSets.Skip(1).Should().BeEmpty("no source operations should have been processed"); } [Fact] @@ -276,15 +282,16 @@ public void SourceErrorsImmediately_ErrorIsPropagated() return Disposable.Empty; }); - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); results.Error.Should().Be(error); - results.IsCompleted.Should().BeFalse(); - results.Messages.Count.Should().Be(1, "1 source operation was performed"); - results.Data.Items.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); + results.HasCompleted.Should().BeFalse(); + results.RecordedChangeSets.Count.Should().Be(1, "1 source operation was performed"); + results.RecordedItemsByKey.Values.Should().BeEquivalentTo(new[] { item1 }, "1 item was added"); } [Fact] @@ -299,19 +306,20 @@ public void SuppressEmptyChangesetsIsFalse_EmptyChangesetsArePublished() { using var source = new Subject>(); - using var results = source + using var subscription = source .FilterImmutable( predicate: Item.Predicate, suppressEmptyChangeSets: false) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); ManipulateExcludedItems(source); results.Error.Should().BeNull(); - results.IsCompleted.Should().BeFalse(); - results.Messages.Count.Should().Be(5, "5 source operations were performed"); - results.Messages.Should().AllSatisfy(changes => changes.Should().BeEmpty(), "no included items were manipulated"); + results.HasCompleted.Should().BeFalse(); + results.RecordedChangeSets.Count.Should().Be(5, "5 source operations were performed"); + results.RecordedChangeSets.Should().AllSatisfy(changes => changes.Should().BeEmpty(), "no included items were manipulated"); } [Fact] @@ -319,16 +327,17 @@ public void SuppressEmptyChangesetsIsTrue_EmptyChangesetsAreNotPublished() { using var source = new Subject>(); - using var results = source + using var subscription = source .FilterImmutable(predicate: Item.Predicate) - .AsAggregator(); + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); ManipulateExcludedItems(source); results.Error.Should().BeNull(); - results.IsCompleted.Should().BeFalse(); - results.Messages.Should().BeEmpty("no source operations should have generated changes"); + results.HasCompleted.Should().BeFalse(); + results.RecordedChangeSets.Should().BeEmpty("no source operations should have generated changes"); } private static void ManipulateExcludedItems(ISubject> source) @@ -364,6 +373,40 @@ private static void ManipulateExcludedItems(ISubject> sour }); } + [Fact] + public void Update_PreviousMatchedCurrentDoesNot_EmitsRemoveCarryingPreviousAsCurrent() + { + // Per Change contract, a Remove change carries the item that was removed in Current. + // For an Update where Previous matched the predicate but Current does not, the item that + // leaves the filtered view is Previous (it was downstream; Current never reached downstream). + using var source = new Subject>(); + + using var subscription = source + .FilterImmutable(predicate: Item.Predicate) + .ValidateChangeSets(Item.KeySelector) + .RecordCacheItems(out var results); + + var included = new Item() { Id = 1, IsIncluded = true }; + var excluded = new Item() { Id = 1, IsIncluded = false }; + + source.OnNext(new ChangeSet() + { + new(reason: ChangeReason.Add, key: included.Id, current: included, index: 0) + }); + + source.OnNext(new ChangeSet() + { + new(reason: ChangeReason.Update, key: excluded.Id, current: excluded, previous: included, currentIndex: 0, previousIndex: 0) + }); + + var lastChangeSet = results.RecordedChangeSets[results.RecordedChangeSets.Count - 1]; + lastChangeSet.Count.Should().Be(1); + + var removeChange = lastChangeSet.Single(); + removeChange.Reason.Should().Be(ChangeReason.Remove); + removeChange.Current.Should().BeSameAs(included, "Remove.Current must carry the item that left downstream (the previously-matching value), not the new value that never reached downstream"); + } + private class Item { public static readonly Func KeySelector diff --git a/src/DynamicData/Cache/Internal/FilterImmutable.cs b/src/DynamicData/Cache/Internal/FilterImmutable.cs index f257b18a1..67f6ec02d 100644 --- a/src/DynamicData/Cache/Internal/FilterImmutable.cs +++ b/src/DynamicData/Cache/Internal/FilterImmutable.cs @@ -74,10 +74,16 @@ public IObservable> Run() if (downstreamReason is { } reason) { // Do not propagate indexes, we can't guarantee them to be correct, because we aren't caching items. + // + // For Update->Remove (Previous matched, Current does not), the item that leaves + // downstream is Previous; Current never reached downstream. Per the Change + // contract, Remove.Current is the value being removed, so carry Previous here. downstreamChanges.Add(new( reason: reason, key: change.Key, - current: change.Current, + current: (reason is ChangeReason.Remove && change.Reason is ChangeReason.Update) + ? change.Previous.Value + : change.Current, previous: (reason is ChangeReason.Update) ? change.Previous : default)); From 0916d9621fe88d17c2f5c2642f6f11348390bd06 Mon Sep 17 00:00:00 2001 From: Alexandre Giard Date: Thu, 18 Jun 2026 19:57:26 -0400 Subject: [PATCH 08/14] test(sum): add more sum tests (#1071) * test(sum): add more sum tests * chore: test renames * test(sum): split `SumFixture` into separate partial classes for cache and list sources (cherry picked from commit b1cb9a1d11cfe2a2d5788614ddfae7c33d621b98) --- .../AggregationTests/SumFixture.ForCache.cs | 517 ++++++++++++++++++ .../AggregationTests/SumFixture.ForList.cs | 413 ++++++++++++++ .../AggregationTests/SumFixture.cs | 227 -------- 3 files changed, 930 insertions(+), 227 deletions(-) create mode 100644 src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs create mode 100644 src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs delete mode 100644 src/DynamicData.Tests/AggregationTests/SumFixture.cs diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs new file mode 100644 index 000000000..078a8f1b0 --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/SumFixture.ForCache.cs @@ -0,0 +1,517 @@ +using System; + +using DynamicData.Aggregation; +using DynamicData.Tests.Domain; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class SumFixture +{ + public class ForCache + { + [Theory] + [InlineData(1, 10)] + [InlineData(3, 60)] + public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) + { + var ages = new[] { 10, 20, 30 }; + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action + for (var i = 0; i < itemCount; i++) + { + source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); + } + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(itemCount, "each AddOrUpdate should produce a new sum emission"); + results.RecordedValues[^1].Should().Be(expectedSum, $"the sum of the first {itemCount} ages should be {expectedSum}"); + } + + [Theory] + [InlineData("A", 50)] + [InlineData("B", 40)] + [InlineData("C", 30)] + public void ItemIsRemoved_SumReflectsRemoval(string keyToRemove, int expectedSum) + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + + // UUT Action + source.Remove(keyToRemove); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal"); + results.RecordedValues[^1].Should().Be(expectedSum, $"removing '{keyToRemove}' should leave a sum of {expectedSum}"); + } + + [Fact] + public void ItemIsUpdated_SumReflectsNewValue() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(30, "the sum of ages 10 + 20 is 30"); + + // UUT Action: update "B" from age 20 to age 50 (same key, new value) + source.AddOrUpdate(new Person("B", 50)); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the update"); + results.RecordedValues[^1].Should().Be(60, "updating 'B' from 20 to 50 should change the sum from 30 to 60"); + } + + [Fact] + public void MultipleChangesInBatch_SingleSumEmitted() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action: add 3 items in a single batch + source.Edit(updater => + { + updater.AddOrUpdate(new Person("A", 10)); + updater.AddOrUpdate(new Person("B", 20)); + updater.AddOrUpdate(new Person("C", 30)); + }); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("a batched edit should produce exactly one sum emission") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void SourceIsEmpty_NoSumEmitted() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void AllItemsRemoved_SumReturnsToZero() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + + // UUT Action: remove all items in a single batch + source.Edit(updater => updater.Clear()); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing"); + results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero"); + } + + [Fact] + public void SourceCompletesAfterEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing the pre-existing item") + .Which.Should().Be(10, "the sum of a single age of 10 is 10"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + } + + [Fact] + public void SourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items were added to the source"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceCompletesImmediately_InitialSumAndCompletionPropagate() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + source.Complete(); + + // UUT Construction: source is already completed, with pre-existing items. + // Subscription should produce both an initial sum and a completion, synchronously. + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().ContainSingle("an initial sum value should still be emitted, even when the source completes immediately upon subscription") + .Which.Should().Be(60, "the sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void SourceCompletesImmediatelyWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.Complete(); + + // UUT Construction: source is already completed, with no items. + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceErrorsAfterEmitting_ErrorPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing the pre-existing item"); + + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Fact] + public void SourceErrorsWithoutEmitting_ErrorPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items were added to the source"); + + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceFailsImmediately_ErrorPropagates() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + var error = new Exception("Test error"); + source.SetError(error); + + // UUT Construction: source is already in error state. + // The error should propagate synchronously upon subscription. + using var subscription = source.Connect() + .Sum(p => p.Age) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber immediately upon subscription"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Fact] + public void NullableValuesAreTreatedAsZero() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", new int?(10), "F", null)); + source.AddOrUpdate(new Person("B", null, "F", null)); + source.AddOrUpdate(new Person("C", new int?(30), "F", null)); + + // UUT Construction + using var subscription = source.Connect() + .Sum(p => p.AgeNullable) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(40, "null values should be treated as zero, so the sum should be 10 + 0 + 30 = 40"); + } + + [Theory] + [InlineData(new[] { 10, 20, 30 }, 60)] + [InlineData(new[] { int.MaxValue }, int.MaxValue)] + [InlineData(new[] { int.MinValue }, int.MinValue)] + [InlineData(new[] { int.MaxValue, -1 }, int.MaxValue - 1)] + [InlineData(new[] { int.MinValue, 1 }, int.MinValue + 1)] + public void ItemsAreAdded_SumIsCorrect_ForInt(int[] ages, int expectedSum) + { + using var source = new TestSourceCache(p => p.Name); + + for (var i = 0; i < ages.Length; i++) + { + source.AddOrUpdate(new Person(((char)('A' + i)).ToString(), ages[i])); + } + + using var subscription = source.Connect() + .Sum(p => p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(expectedSum, $"the int sum of [{string.Join(", ", ages)}] is {expectedSum}"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableInt() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", new int?(10), "F", null)); + source.AddOrUpdate(new Person("B", new int?(20), "F", null)); + source.AddOrUpdate(new Person("C", new int?(30), "F", null)); + + using var subscription = source.Connect() + .Sum(p => p.AgeNullable) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60, "the nullable int sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForLong() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (long)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the long sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableLong() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (long?)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDouble() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (double)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the double sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDouble() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (double?)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDecimal() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (decimal)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the decimal sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (decimal?)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForFloat() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (float)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the float sum of ages 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() + { + using var source = new TestSourceCache(p => p.Name); + + source.AddOrUpdate(new Person("A", 10)); + source.AddOrUpdate(new Person("B", 20)); + source.AddOrUpdate(new Person("C", 30)); + + using var subscription = source.Connect() + .Sum(p => (float?)p.Age) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of ages 10 + 20 + 30 is 60"); + } + } +} diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs new file mode 100644 index 000000000..9e9108602 --- /dev/null +++ b/src/DynamicData.Tests/AggregationTests/SumFixture.ForList.cs @@ -0,0 +1,413 @@ +using System; +using System.Linq; + +using DynamicData.Aggregation; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.AggregationTests; + +public partial class SumFixture +{ + public class ForList + { + [Theory] + [InlineData(1, 10)] + [InlineData(3, 60)] + public void ItemsAreAdded_SumReflectsAllItems(int itemCount, int expectedSum) + { + var items = new[] { 10, 20, 30 }; + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items have been added to the source"); + + // UUT Action + source.AddRange(items.Take(itemCount)); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("an AddRange produces a single changeset") + .Which.Should().Be(expectedSum, $"the sum of the first {itemCount} items should be {expectedSum}"); + } + + [Theory] + [InlineData(0, 50)] + [InlineData(1, 40)] + [InlineData(2, 30)] + public void ItemIsRemoved_SumReflectsRemoval(int removalIndex, int expectedSum) + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action + source.RemoveAt(removalIndex); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the removal"); + results.RecordedValues[^1].Should().Be(expectedSum, $"removing item at index {removalIndex} should leave a sum of {expectedSum}"); + } + + [Fact] + public void ItemIsReplaced_SumReflectsReplacement() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action: replace item at index 1 (value 20) with 50 + source.ReplaceAt(1, 50); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after the replacement"); + results.RecordedValues[^1].Should().Be(90, "replacing 20 with 50 should change the sum from 60 to 90"); + } + + [Fact] + public void ItemsAreCleared_SumReturnsToZero() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + + // UUT Action + source.Clear(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().HaveCount(2, "one additional sum value should have been emitted after clearing"); + results.RecordedValues[^1].Should().Be(0, "all items were removed so the sum should return to zero"); + } + + [Fact] + public void SourceIsEmpty_NoSumEmitted() + { + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceCompletesAfterEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeFalse("the source can still publish notifications"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + } + + [Fact] + public void SourceCompletesWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.RecordedValues.Should().BeEmpty("no items were added to the source"); + + // UUT Action + source.Complete(); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source has completed"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceCompletesImmediately_InitialSumAndCompletionPropagate() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + source.Complete(); + + // UUT Construction: source is already completed, with pre-existing items. + // Subscription should produce both an initial sum and a completion, synchronously. + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().ContainSingle("an initial sum value should still be emitted, even when the source completes immediately upon subscription") + .Which.Should().Be(60, "the sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void SourceCompletesImmediatelyWithoutEmitting_CompletionPropagates() + { + using var source = new TestSourceList(); + + source.Complete(); + + // UUT Construction: source is already completed, with no items. + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.HasCompleted.Should().BeTrue("the source was already completed at the time of subscription"); + results.RecordedValues.Should().BeEmpty("no items were added so no sum values should have been emitted"); + } + + [Fact] + public void SourceErrorsAfterEmitting_ErrorPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + // UUT Construction + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeNull("no errors should have occurred"); + results.RecordedValues.Should().ContainSingle("one changeset was published containing all pre-existing items"); + + // UUT Action + var error = new Exception("Test error"); + source.SetError(error); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Fact] + public void SourceFailsImmediately_ErrorPropagates() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + var error = new Exception("Test error"); + source.SetError(error); + + // UUT Construction: source is already in error state. + // The error should propagate synchronously upon subscription. + using var subscription = source.Connect() + .Sum(x => x) + .ValidateSynchronization() + .RecordValues(out var results); + + results.Error.Should().BeSameAs(error, "the error from the source should propagate to the subscriber immediately upon subscription"); + results.HasCompleted.Should().BeFalse("an error is not a completion"); + } + + [Theory] + [InlineData(new[] { 10, 20, 30 }, 60)] + [InlineData(new[] { int.MaxValue }, int.MaxValue)] + [InlineData(new[] { int.MinValue }, int.MinValue)] + [InlineData(new[] { int.MaxValue, -1 }, int.MaxValue - 1)] + [InlineData(new[] { int.MinValue, 1 }, int.MinValue + 1)] + public void ItemsAreAdded_SumIsCorrect_ForInt(int[] values, int expectedSum) + { + using var source = new TestSourceList(); + + source.AddRange(values); + + using var subscription = source.Connect() + .Sum(x => x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(expectedSum, $"the int sum of [{string.Join(", ", values)}] is {expectedSum}"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableInt() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (int?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60, "the nullable int sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForLong() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (long)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the long sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableLong() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (long?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60L, "the nullable long sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDouble() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (double)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the double sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDouble() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (double?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60.0, "the nullable double sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForDecimal() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (decimal)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the decimal sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableDecimal() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (decimal?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60M, "the nullable decimal sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForFloat() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (float)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the float sum of items 10 + 20 + 30 is 60"); + } + + [Fact] + public void ItemsAreAdded_SumIsCorrect_ForNullableFloat() + { + using var source = new TestSourceList(); + + source.AddRange(new[] { 10, 20, 30 }); + + using var subscription = source.Connect() + .Sum(x => (float?)x) + .RecordValues(out var results); + + results.RecordedValues[^1].Should().Be(60F, "the nullable float sum of items 10 + 20 + 30 is 60"); + } + } +} diff --git a/src/DynamicData.Tests/AggregationTests/SumFixture.cs b/src/DynamicData.Tests/AggregationTests/SumFixture.cs deleted file mode 100644 index 6fecbcabc..000000000 --- a/src/DynamicData.Tests/AggregationTests/SumFixture.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; - -using DynamicData.Aggregation; -using DynamicData.Tests.Domain; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.AggregationTests; - -public class SumFixture : IDisposable -{ - private readonly SourceCache _source; - - public SumFixture() => _source = new SourceCache(p => p.Name); - - [Fact] - public void AddedItemsContributeToSum() - { - var sum = 0; - double dev = 0; - - var accumulator = _source.Connect().Sum(p => p.Age).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (int)0).Subscribe(x => dev = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumLong() - { - long sum = 0; - double dev = 0; - - var accumulator = _source.Connect().Sum(p => Convert.ToInt64(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (long)0).Subscribe(x => dev = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumFloat() - { - float sum = 0; - double dev = 0; - - var accumulator = _source.Connect().Sum(p => Convert.ToSingle(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (float)0).Subscribe(x => dev = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumDouble() - { - double sum = 0; - double dev = 0; - - var accumulator = _source.Connect().Sum(p => Convert.ToDouble(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (double)0).Subscribe(x => dev = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654755, ""); - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumDecimal() - { - decimal sum = 0; - decimal dev = 0; - - var accumulator = _source.Connect().Sum(p => Convert.ToDecimal(p.Age)).Subscribe(x => sum = x); - var deviation = _source.Connect().StdDev(p => p.Age, (decimal)0).Subscribe(x => dev = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - sum.Should().Be(60, "Accumulated value should be 60"); - dev.Should().Be(7.0710678118654752440084436210M, ""); - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumNullable() - { - var sum = 0; - - var accumulator = _source.Connect().Sum(p => p.AgeNullable).Subscribe(x => sum = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - sum.Should().Be(60, "Accumulated value should be 60"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumLongNullable() - { - long sum = 0; - - var accumulator = _source.Connect().Sum(p => (long?)(p.AgeNullable.HasValue ? Convert.ToInt64(p.AgeNullable) : default)).Subscribe(x => sum = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - sum.Should().Be(60, "Accumulated value should be 60"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumFloatNullable() - { - float sum = 0; - - var accumulator = _source.Connect().Sum(p => (float?)(p.AgeNullable.HasValue ? Convert.ToSingle(p.AgeNullable) : default)).Subscribe(x => sum = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - sum.Should().Be(60, "Accumulated value should be 60"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumDoubleNullable() - { - double sum = 0; - - var accumulator = _source.Connect().Sum(p => (double?)(p.AgeNullable.HasValue ? Convert.ToDouble(p.AgeNullable) : default)).Subscribe(x => sum = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - sum.Should().Be(60, "Accumulated value should be 60"); - - accumulator.Dispose(); - } - - [Fact] - public void AddedItemsContributeToSumDecimalNullable() - { - decimal sum = 0; - - var accumulator = _source.Connect().Sum(p => (decimal?)(p.AgeNullable.HasValue ? Convert.ToDecimal(p.AgeNullable) : default)).Subscribe(x => sum = x); - - _source.AddOrUpdate(new Person("A", new int?(10), "F", null)); - _source.AddOrUpdate(new Person("B", new int?(20), "F", null)); - _source.AddOrUpdate(new Person("C", new int?(30), "F", null)); - - sum.Should().Be(60, "Accumulated value should be 60"); - - accumulator.Dispose(); - } - - public void Dispose() => _source.Dispose(); - - [Fact] - public void InlineChangeReEvaluatesTotals() - { - var sum = 0; - - var somepropChanged = _source.Connect().WhenValueChanged(p => p.Age); - - var accumulator = _source.Connect().Sum(p => p.Age).InvalidateWhen(somepropChanged).Subscribe(x => sum = x); - - var personb = new Person("B", 5); - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(personb); - _source.AddOrUpdate(new Person("C", 30)); - - sum.Should().Be(45, "Sum should be 45 after inline change"); - - personb.Age = 20; - - sum.Should().Be(60, "Sum should be 60 after inline change"); - accumulator.Dispose(); - } - - [Fact] - public void RemoveProduceCorrectResult() - { - var sum = 0; - - var accumulator = _source.Connect().Sum(p => p.Age).Subscribe(x => sum = x); - - _source.AddOrUpdate(new Person("A", 10)); - _source.AddOrUpdate(new Person("B", 20)); - _source.AddOrUpdate(new Person("C", 30)); - - _source.Remove("A"); - sum.Should().Be(50, "Accumulated value should be 50 after remove"); - accumulator.Dispose(); - } -} From b51dcd53a01b9c06dcd099a7f0e0904fc82f5722 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:08:45 +0100 Subject: [PATCH 09/14] Update dotnet/nbgv action to v0.5.2 (#1102) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> (cherry picked from commit 5610bb45a8941919826ef572dd2e50635aa25bb6) --- .github/workflows/ci-build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ebafe1cf8..3d81352bc 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -39,7 +39,7 @@ jobs: - name: NBGV id: nbgv - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 with: setAllVars: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 643ae46fd..751b0ca90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: - name: NBGV id: nbgv - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 with: setAllVars: true From 7868341db462a09966ee303eda46c6c97950ed08 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:22:14 +0000 Subject: [PATCH 10/14] Update actions/setup-dotnet action to v5.3.0 (#1058) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> (cherry picked from commit aaa4ed1234006aa5d8bd76cb2db12c9d7bd2e28f) --- .github/workflows/ci-build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 3d81352bc..1b588dd07 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -21,7 +21,7 @@ jobs: lfs: true - name: Setup .NET (With cache) - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: | 6.0.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 751b0ca90..f629fd45a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: Write-Host "OK: publishing from '$env:REF_NAME'." - name: Setup .NET (With cache) - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: | 6.0.x From b42d8f9f3727f40a975d7b96d80bfb847dd33762 Mon Sep 17 00:00:00 2001 From: Glenn <5834289+glennawatson@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:46:21 +1000 Subject: [PATCH 11/14] ci: adopt GitReleaseNoteGenerator and nbgv CLI tool (#1125) - Replace the glennawatson/ChangeLog action with the GitReleaseNoteGenerator global tool (git-release-notes) to produce release notes, matching the reactiveui pipeline. Resolves #1093. - Swap the stale dotnet/nbgv JS action for the nbgv global tool, stamping cloud variables via `nbgv cloud -a` and exposing SemVer2/PrereleaseVersion as step outputs. - Keep Nerdbank.GitVersioning (version.json) as the version source; all branch/version policy checks are unchanged. (cherry picked from commit f933ae5dc87d825d0fabf524cdf20bbf824433be) --- .github/workflows/ci-build.yml | 18 +++++++++++++----- .github/workflows/release.yml | 33 ++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 1b588dd07..e5f9f3baf 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -37,12 +37,20 @@ jobs: **/global.json **/nuget.config - - name: NBGV + - name: Install (or update) nbgv tool + run: dotnet tool update --global nbgv + + - name: Set NBGV cloud variables + run: nbgv cloud -a + + - name: Expose NBGV version as step outputs id: nbgv - uses: dotnet/nbgv@v0.5.2 - with: - setAllVars: true - + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + "SemVer2=$env:NBGV_SemVer2" >> $env:GITHUB_OUTPUT + "PrereleaseVersion=$env:NBGV_PrereleaseVersion" >> $env:GITHUB_OUTPUT + - name: NuGet Restore run: dotnet restore DynamicData.sln working-directory: src diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f629fd45a..50f8371d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,11 +56,19 @@ jobs: **/global.json **/nuget.config - - name: NBGV + - name: Install (or update) nbgv tool + run: dotnet tool update --global nbgv + + - name: Set NBGV cloud variables + run: nbgv cloud -a + + - name: Expose NBGV version as step outputs id: nbgv - uses: dotnet/nbgv@v0.5.2 - with: - setAllVars: true + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + "SemVer2=$env:NBGV_SemVer2" >> $env:GITHUB_OUTPUT + "PrereleaseVersion=$env:NBGV_PrereleaseVersion" >> $env:GITHUB_OUTPUT - name: Verify version matches branch policy shell: pwsh @@ -126,21 +134,24 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($pkg.Name) (exit $LASTEXITCODE)." } } - - name: Changelog - uses: glennawatson/ChangeLog@0464dd89b26f61fecf24b41d675f8ffdb11c4c3f # v1 - id: changelog + - name: Install GitReleaseNoteGenerator + run: dotnet tool install -g GitReleaseNoteGenerator + + - name: Generate release notes + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.nbgv.outputs.SemVer2 }} + shell: pwsh + run: git-release-notes --release-version "$env:RELEASE_VERSION" --output-file release-notes.md - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.nbgv.outputs.SemVer2 }} IS_PRERELEASE: ${{ steps.nbgv.outputs.PrereleaseVersion != '' }} - BODY: ${{ steps.changelog.outputs.commitLog }} shell: pwsh run: | $ErrorActionPreference = 'Stop' - $notesPath = Join-Path $env:RUNNER_TEMP 'release-notes.md' - Set-Content -Path $notesPath -Value $env:BODY -Encoding utf8 -NoNewline - $cmd = @('release', 'create', $env:TAG, '--title', $env:TAG, '--notes-file', $notesPath, '--target', $env:GITHUB_SHA) + $cmd = @('release', 'create', $env:TAG, '--title', $env:TAG, '--notes-file', 'release-notes.md', '--target', $env:GITHUB_SHA) if ($env:IS_PRERELEASE -eq 'true') { $cmd += '--prerelease' } gh @cmd From 09ce42475db12511586a0faae975e38d246b95a8 Mon Sep 17 00:00:00 2001 From: John Cummings Date: Sat, 4 Jul 2026 00:37:37 -0500 Subject: [PATCH 12/14] Fix Exception in list static filter index assumptions (#1120) * Add RemoveKey tests showing issue with Refresh and Filter * Fix index out of range issue with static List Filter * Remove unused variable in RemoveKeyFixture * Remove unused variable in RemoveKeyFixture (really) * Revert formatting to previous in Filter.Static.cs * Add unit test changing order or RemoveKey call * Update RemoveKey test names based on PR feedback * Remove extraneous comment per PR feedback * Move Filter-related RemoveKey tests to FilterFixture --------- Co-authored-by: John Cummings (cherry picked from commit d26b63c9b2202f40c957647e5aaff529397296a9) --- .../Cache/FilterFixture.Static.cs | 53 +++++++++++ .../Cache/RemoveKeyFixture.cs | 95 +++++++++++++++++++ .../List/Internal/Filter.Static.cs | 17 +++- 3 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/DynamicData.Tests/Cache/RemoveKeyFixture.cs diff --git a/src/DynamicData.Tests/Cache/FilterFixture.Static.cs b/src/DynamicData.Tests/Cache/FilterFixture.Static.cs index f3d61ac0a..1ca4de0cd 100644 --- a/src/DynamicData.Tests/Cache/FilterFixture.Static.cs +++ b/src/DynamicData.Tests/Cache/FilterFixture.Static.cs @@ -6,6 +6,9 @@ using Xunit; using DynamicData.Tests.Utilities; +using DynamicData.Tests.Domain; +using System.Collections.ObjectModel; +using System.Linq; namespace DynamicData.Tests.Cache; @@ -86,5 +89,55 @@ protected override IObservable> BuildUut( => source.Filter( filter: predicate, suppressEmptyChangeSets: suppressEmptyChangeSets); + [Fact] + public void AutoRefreshRemoveKeyFilterUpdate_CollectionUpdated() + { + RandomPersonGenerator generator = new(); + using var source = new SourceCache(p => p.Key); + var people = generator.Take(100).ToArray(); + var average = people.Average(x => x.Age); + ReadOnlyObservableCollection collection; + using var subscription = source.Connect() + .AutoRefresh(x => x.Age) + .RemoveKey() + .Filter(x => x.Age < average) + .Bind(out collection) + .Subscribe(); + source.AddOrUpdate(people); + + Assert.Equivalent(people.Where(x => x.Age < average), collection); + + foreach (var person in people) + { + person.Age = person.Age + 1; + } + Assert.Equivalent(people.Where(x => x.Age < average), collection); + } + + [Fact] + public void AutoRefreshFilterRemoveKeyUpdate_CollectionUpdated() + { + RandomPersonGenerator generator = new(); + using var source = new SourceCache(p => p.Key); + var people = generator.Take(100).ToArray(); + var average = people.Average(x => x.Age); + ReadOnlyObservableCollection collection; + using var subscription = source.Connect() + .AutoRefresh(x => x.Age) + .Filter(x => x.Age < average) + .RemoveKey() + .Bind(out collection) + .Subscribe(); + source.AddOrUpdate(people); + + Assert.Equivalent(people.Where(x => x.Age < average), collection); + + foreach (var person in people) + { + person.Age = person.Age + 1; + } + Assert.Equivalent(people.Where(x => x.Age < average), collection); + } } + } diff --git a/src/DynamicData.Tests/Cache/RemoveKeyFixture.cs b/src/DynamicData.Tests/Cache/RemoveKeyFixture.cs new file mode 100644 index 000000000..04d27f619 --- /dev/null +++ b/src/DynamicData.Tests/Cache/RemoveKeyFixture.cs @@ -0,0 +1,95 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Disposables; + +using DynamicData.Binding; +using DynamicData.Tests.Domain; + +using FluentAssertions; + +using Xunit; + +#endregion + +namespace DynamicData.Tests.Cache; + +public class RemoveKeyFixture : IDisposable +{ + private readonly RandomPersonGenerator _generator = new(); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Handled with CompositeDisposable")] + private readonly ISourceCache _source; + + private readonly CompositeDisposable _cleanup = new(); + + public RemoveKeyFixture() + { + _source = new SourceCache(p => p.Key); + _cleanup.Add(_source); + } + + public void Dispose() => _cleanup.Dispose(); + + [Fact] + public void CacheRemoveKey_Add_KeyIsRemoved() + { + ReadOnlyObservableCollection collection; + _cleanup.Add( + _source.Connect() + .RemoveKey() + .Bind(out collection) + .Subscribe() + ); + var people = _generator.Take(100).ToArray(); + _source.AddOrUpdate(people); + + Assert.Equivalent(people, collection); + } + + [Fact] + public void CacheRemoveKey_Filter_ItemsFilterKeyIsRemoved() + { + var people = _generator.Take(100).ToArray(); + var average = people.Average(x => x.Age); + + ReadOnlyObservableCollection collection; + _cleanup.Add( + _source.Connect() + .RemoveKey() + .Filter(x => x.Age < average) + .Bind(out collection) + .Subscribe() + ); + _source.AddOrUpdate(people); + + Assert.Equivalent(people.Where(x => x.Age < average), collection); + } + + [Fact] + public void CacheRemoveKey_AutoRefreshUpdateITems_CollectionUpdated() + { + ReadOnlyObservableCollection collection; + _cleanup.Add( + _source.Connect() + .AutoRefresh(x => x.Age) + .RemoveKey() + .Bind(out collection) + .Subscribe() + ); + var people = _generator.Take(100).ToArray(); + _source.AddOrUpdate(people); + + Assert.Equivalent(people, collection); + + foreach (var person in people) + { + person.Age = person.Age + 1; + } + Assert.Equivalent(people, collection); + } + +} diff --git a/src/DynamicData/List/Internal/Filter.Static.cs b/src/DynamicData/List/Internal/Filter.Static.cs index 36a49c5e9..48b12020c 100644 --- a/src/DynamicData/List/Internal/Filter.Static.cs +++ b/src/DynamicData/List/Internal/Filter.Static.cs @@ -26,7 +26,6 @@ public static IObservable> Create( var downstreamItems = new ChangeAwareList(); var itemsBuffer = new List(); - var downstream = source.Select(upstreamChanges => { foreach (var change in upstreamChanges) @@ -212,13 +211,23 @@ public static IObservable> Create( { var isIncluded = predicate.Invoke(change.Item.Current); - var itemState = upstreamItemsStates[change.Item.CurrentIndex]; - upstreamItemsStates[change.Item.CurrentIndex] = ( + var currentIndex = change.Item.CurrentIndex; + // A Replace might have a negative CurrentIndex from a Refresh in RemoveKeyEnumerator + if (currentIndex < 0) + { + var previous = upstreamItemsStates.Select(x => x.item) + .IndexOfOptional(change.Item.Current) + .ValueOrThrow(() => new InvalidOperationException($"Cannot find index of {typeof(T).Name} -> {change.Item.Current}. Expected to be in the list")); + currentIndex = previous.Index; + } + var itemState = upstreamItemsStates[currentIndex]; + + upstreamItemsStates[currentIndex] = ( item: change.Item.Current, isIncluded: isIncluded); var downstreamIndex = (isIncluded || itemState.isIncluded) - ? change.Item.CurrentIndex - CountExcludedItemsBefore(change.Item.CurrentIndex) + ? currentIndex - CountExcludedItemsBefore(currentIndex) : -1; switch (itemState.isIncluded, isIncluded) From 437ca1fc65b19da0005a2d99d42413fe3241791b Mon Sep 17 00:00:00 2001 From: Jake Meiergerd Date: Thu, 9 Jul 2026 15:05:32 -0700 Subject: [PATCH 13/14] Added support for automatic nesting of source files with hierarchal naming. (#1134) Co-authored-by: Darrin W. Cullop (cherry picked from commit e5e8e81801e7eea62b888141d315813a1ff25880) --- src/Directory.Build.targets | 7 +++++++ .../Cache/FilterFixture.DynamicPredicate.cs | 6 ++++++ .../FilterFixture.DynamicPredicateAndReFiltering.cs | 6 ++++++ .../Cache/FilterFixture.DynamicPredicateState.cs | 6 ++++++ .../Cache/ToObservableChangeSetFixture.Items.cs | 6 ++++++ .../Cache/ToObservableChangeSetFixture.Sequences.cs | 6 ++++++ .../List/ToObservableChangeSetFixture.Items.cs | 6 ++++++ .../List/ToObservableChangeSetFixture.Sequences.cs | 6 ++++++ src/DynamicData/Cache/Internal/ExpireAfter.cs | 7 +++++++ src/DynamicData/Cache/Internal/Filter.cs | 7 +++++++ src/DynamicData/DynamicData.csproj | 12 +----------- src/DynamicData/List/Internal/Filter.cs | 7 +++++++ 12 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs create mode 100644 src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs create mode 100644 src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs create mode 100644 src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs create mode 100644 src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs create mode 100644 src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs create mode 100644 src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs create mode 100644 src/DynamicData/Cache/Internal/ExpireAfter.cs create mode 100644 src/DynamicData/Cache/Internal/Filter.cs create mode 100644 src/DynamicData/List/Internal/Filter.cs diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 347ab888d..d341fbbbd 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -3,6 +3,13 @@ $(AssemblyName) ($(TargetFramework)) + + + + $([System.Text.RegularExpressions.Regex]::Replace(%(Filename), '\.[^\.]+$', '.cs')) + + + $(DefineConstants);P_LINQ;SUPPORTS_BINDINGLIST diff --git a/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs new file mode 100644 index 000000000..e5440f71b --- /dev/null +++ b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicate.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.Cache; + +public static partial class FilterFixture +{ + public static partial class DynamicPredicate; +} diff --git a/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs new file mode 100644 index 000000000..97ddee168 --- /dev/null +++ b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateAndReFiltering.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.Cache; + +public static partial class FilterFixture +{ + public static partial class DynamicPredicateAndReFiltering; +} diff --git a/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs new file mode 100644 index 000000000..5d191eb59 --- /dev/null +++ b/src/DynamicData.Tests/Cache/FilterFixture.DynamicPredicateState.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.Cache; + +public static partial class FilterFixture +{ + public static partial class DynamicPredicateState; +} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs new file mode 100644 index 000000000..4062e343b --- /dev/null +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Items.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.Cache; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Items; +} diff --git a/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs new file mode 100644 index 000000000..cc57d44dc --- /dev/null +++ b/src/DynamicData.Tests/Cache/ToObservableChangeSetFixture.Sequences.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.Cache; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Sequences; +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs new file mode 100644 index 000000000..c6531d5fa --- /dev/null +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Items.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.List; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Items; +} diff --git a/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs new file mode 100644 index 000000000..1a6445a49 --- /dev/null +++ b/src/DynamicData.Tests/List/ToObservableChangeSetFixture.Sequences.cs @@ -0,0 +1,6 @@ +namespace DynamicData.Tests.List; + +public static partial class ToObservableChangeSetFixture +{ + public static partial class Sequences; +} diff --git a/src/DynamicData/Cache/Internal/ExpireAfter.cs b/src/DynamicData/Cache/Internal/ExpireAfter.cs new file mode 100644 index 000000000..2c9239f51 --- /dev/null +++ b/src/DynamicData/Cache/Internal/ExpireAfter.cs @@ -0,0 +1,7 @@ +// Copyright (c) 2011-2025 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.Cache.Internal; + +internal static partial class ExpireAfter; diff --git a/src/DynamicData/Cache/Internal/Filter.cs b/src/DynamicData/Cache/Internal/Filter.cs new file mode 100644 index 000000000..dabc3ba7d --- /dev/null +++ b/src/DynamicData/Cache/Internal/Filter.cs @@ -0,0 +1,7 @@ +// Copyright (c) 2011-2025 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.Cache.Internal; + +internal static partial class Filter; diff --git a/src/DynamicData/DynamicData.csproj b/src/DynamicData/DynamicData.csproj index f714918e7..fa2926c66 100644 --- a/src/DynamicData/DynamicData.csproj +++ b/src/DynamicData/DynamicData.csproj @@ -24,19 +24,9 @@ Dynamic Data is a comprehensive caching and data manipulation solution which int - - - - - - - ObservableCacheEx.cs - - - - \ No newline at end of file + diff --git a/src/DynamicData/List/Internal/Filter.cs b/src/DynamicData/List/Internal/Filter.cs new file mode 100644 index 000000000..b43fc2dd6 --- /dev/null +++ b/src/DynamicData/List/Internal/Filter.cs @@ -0,0 +1,7 @@ +// Copyright (c) 2011-2025 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 static partial class Filter; From 9d74b4a0d43cf6dcbb13d0273809d6091eed1dca Mon Sep 17 00:00:00 2001 From: Glen Nicol Date: Sun, 19 Jul 2026 20:49:03 -0700 Subject: [PATCH 14/14] Feature: Cancellation support for TransformAsync (#1135) (cherry picked from commit dfef2397061effddf89250aedcd70c0410a8e369) --- ...ts.DynamicDataTests.DotNet9_0.verified.txt | 21 ++++- .../Cache/TransformAsyncFixture.cs | 29 +++++- .../Cache/TransformSafeAsyncFixture.cs | 25 +++++- .../List/TransformAsyncFixture.cs | 24 ++++- .../Cache/Internal/TransformAsync.cs | 10 +-- .../Cache/ObservableCacheEx.TransformAsync.cs | 32 ++++++- .../ObservableCacheEx.TransformSafeAsync.cs | 31 ++++++- .../List/Internal/TransformAsync.cs | 88 ++++++++++++------- .../List/ObservableListEx.TransformAsync.cs | 20 ++++- 9 files changed, 232 insertions(+), 48 deletions(-) diff --git a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt index ff1f94916..a225bfe5f 100644 --- a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt +++ b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet9_0.verified.txt @@ -1964,6 +1964,14 @@ namespace DynamicData where TDestination : notnull where TSource : notnull where TKey : notnull { } + public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, DynamicData.TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } + public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } public static System.IObservable> TransformImmutable(this System.IObservable> source, System.Func transformFactory) where TDestination : notnull where TSource : notnull @@ -2108,6 +2116,14 @@ namespace DynamicData where TDestination : notnull where TSource : notnull where TKey : notnull { } + public static System.IObservable> TransformSafeAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.Action> errorHandler, DynamicData.TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } + public static System.IObservable> TransformSafeAsync(this System.IObservable> source, System.Func, TKey, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, System.Action> errorHandler, System.IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { } public static System.IObservable, TKey>> TransformToTree(this System.IObservable> source, System.Func pivotOn, System.IObservable, bool>>? predicateChanged = null) where TObject : class where TKey : notnull { } @@ -2481,6 +2497,9 @@ namespace DynamicData public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, int, System.Threading.Tasks.Task> transformFactory, bool transformOnRefresh = false) where TSource : notnull where TDestination : notnull { } + public static System.IObservable> TransformAsync(this System.IObservable> source, System.Func, int, System.Threading.CancellationToken, System.Threading.Tasks.Task> transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull { } public static System.IObservable> TransformMany(this System.IObservable> source, System.Func> manySelector, System.Collections.Generic.IEqualityComparer? equalityComparer = null) where TDestination : notnull where TSource : notnull { } @@ -3100,4 +3119,4 @@ namespace DynamicData.Tests public void Dispose() { } protected virtual void Dispose(bool isDisposing) { } } -} +} \ No newline at end of file diff --git a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs index 7483eccb4..b1cedaefe 100644 --- a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs +++ b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs @@ -200,7 +200,7 @@ public void Update() } - + [Theory, InlineData(true), InlineData(false)] public void TransformOnRefresh(bool transformOnRefresh) @@ -215,7 +215,7 @@ public void TransformOnRefresh(bool transformOnRefresh) results.Data.Count.Should().Be(1); results.Data.Lookup("SomeOne").Value.AgeGroup.Should().Be("Child"); - + person.Age = 21; @@ -224,7 +224,28 @@ public void TransformOnRefresh(bool transformOnRefresh) } - + [Fact] + public void TransformAsyncCancelsTokenOnUnSubscribe() + { + using var source = new SourceCache(p => p.Name); + var tcs = new TaskCompletionSource(); + using var sub = source.Connect() + .TransformAsync(async (c, p, key, cancel) => + { + using (cancel.Register(() => tcs.SetCanceled(), useSynchronizationContext: false)) + { + return await tcs.Task.ConfigureAwait(false); + } + }) + .Subscribe(); + + source.AddOrUpdate(new Person()); + + sub.Dispose(); + Assert.True(tcs.Task.IsCanceled); + } + + [Theory, InlineData(10), InlineData(100)] public async Task WithMaxConcurrency(int maxConcurrency) @@ -232,7 +253,7 @@ public async Task WithMaxConcurrency(int maxConcurrency) /* We need to test whether the max concurrency has any effect. If maxConcurrency == 100, this test takes a little more than 100 ms - If maxConcurrency = 10, this test takes a little more than 1s + If maxConcurrency = 10, this test takes a little more than 1s So it works, but how can it be tested in a scientific way ?? */ diff --git a/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs b/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs index 6c1f34eff..b0b26cfb6 100644 --- a/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs +++ b/src/DynamicData.Tests/Cache/TransformSafeAsyncFixture.cs @@ -208,6 +208,29 @@ public void TransformOnRefresh(bool transformOnRefresh) } + [Fact] + public void TransformSafeAsyncCancelsTokenOnUnSubscribe() + { + using var source = new SourceCache(p => p.Name); + var tcs = new TaskCompletionSource(); + using var sub = source.Connect() + .TransformSafeAsync(async (c, p, key, cancel) => + { + using (cancel.Register(() => tcs.SetCanceled(), useSynchronizationContext: false)) + { + return await tcs.Task.ConfigureAwait(false); + } + }, + error => Assert.Fail($"Unexpected error: {error}")) // NOTE: Cancellation exception should not be called because the handler should be torn down with subscription + .Subscribe(); + + source.AddOrUpdate(new Person()); + + sub.Dispose(); + Assert.True(tcs.Task.IsCanceled); + } + + [Theory, InlineData(10), InlineData(100)] public async Task WithMaxConcurrency(int maxConcurrency) @@ -215,7 +238,7 @@ public async Task WithMaxConcurrency(int maxConcurrency) /* We need to test whether the max concurrency has any effect. If maxConcurrency == 100, this test takes a little more than 100 ms - If maxConcurrency = 10, this test takes a little more than 1s + If maxConcurrency = 10, this test takes a little more than 1s So it works, but how can it be tested in a scientific way ?? */ diff --git a/src/DynamicData.Tests/List/TransformAsyncFixture.cs b/src/DynamicData.Tests/List/TransformAsyncFixture.cs index 9d0bdb447..be4b8f623 100755 --- a/src/DynamicData.Tests/List/TransformAsyncFixture.cs +++ b/src/DynamicData.Tests/List/TransformAsyncFixture.cs @@ -1,7 +1,8 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; - +using DynamicData.Kernel; using DynamicData.Tests.Domain; using FluentAssertions; using Xunit; @@ -165,4 +166,25 @@ private void TransformOnRefresh() results.Messages.Last().First().Reason.Should().Be(ListChangeReason.Replace); } + + [Fact] + public void TransformAsyncCancelsTokenOnUnSubscribe() + { + using var source = new SourceList(); + var tcs = new TaskCompletionSource(); + using var sub = source.Connect() + .TransformAsync(async (c, p, count, cancel) => + { + using (cancel.Register(() => tcs.SetCanceled(), useSynchronizationContext: false)) + { + return await tcs.Task.ConfigureAwait(false); + } + }) + .Subscribe(); + + source.Add(new Person()); + + sub.Dispose(); + Assert.True(tcs.Task.IsCanceled); + } } diff --git a/src/DynamicData/Cache/Internal/TransformAsync.cs b/src/DynamicData/Cache/Internal/TransformAsync.cs index 344adc621..2ace51ef9 100644 --- a/src/DynamicData/Cache/Internal/TransformAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformAsync.cs @@ -9,7 +9,7 @@ namespace DynamicData.Cache.Internal; internal class TransformAsync( IObservable> source, - Func, TKey, Task> transformFactory, + Func, TKey, CancellationToken, Task> transformFactory, Action>? exceptionCallback, IObservable>? forceTransform = null, int? maximumConcurrency = null, @@ -42,7 +42,7 @@ private IObservable> DoTransform(ChangeAwareCache var toTransform = cache.KeyValues.Where(kvp => shouldTransform(kvp.Value.Source, kvp.Key)).Select(kvp => new Change(ChangeReason.Update, kvp.Key, kvp.Value.Source, kvp.Value.Source)).ToArray(); - return toTransform.Select(change => Observable.Defer(() => Transform(change).ToObservable())) + return toTransform.Select(change => Observable.FromAsync(t => Transform(change, t))) .Merge(maximumConcurrency ?? int.MaxValue) .ToArray() .Select(transformed => ProcessUpdates(cache, transformed)); @@ -51,7 +51,7 @@ private IObservable> DoTransform(ChangeAwareCache private IObservable> DoTransform( ChangeAwareCache cache, IChangeSet changes) { - return changes.Select(change => Observable.FromAsync(() => Transform(change))) + return changes.Select(change => Observable.FromAsync(t => Transform(change, t))) .Merge(maximumConcurrency ?? int.MaxValue) .ToArray() .Select(transformed => ProcessUpdates(cache, transformed)); @@ -102,13 +102,13 @@ private ChangeSet ProcessUpdates(ChangeAwareCache(transformed); } - private async Task Transform(Change change) + private async Task Transform(Change change, CancellationToken cancellationToken) { try { if (change.Reason is ChangeReason.Add or ChangeReason.Update || (change.Reason is ChangeReason.Refresh && transformOnRefresh)) { - var destination = await transformFactory(change.Current, change.Previous, change.Key) + var destination = await transformFactory(change.Current, change.Previous, change.Key, cancellationToken) .ConfigureAwait(false); return new TransformResult(change, new TransformedItemContainer(change.Current, destination)); } diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs index dc13c5a2f..5e76e6cb8 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformAsync.cs @@ -54,6 +54,20 @@ public static IObservable> TransformAsync transformFactory(current, key), forceTransform); } + /// + /// This overload takes a factory that receives the current item the previous item and key. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((current, previous, key, _) => transformFactory(current, previous, key), forceTransform); + } + /// /// Async version of . /// Projects each item using an async factory that returns . @@ -88,7 +102,7 @@ public static IObservable> TransformAsync /// or is . [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync(this IObservable> source, Func, TKey, Task> transformFactory, IObservable>? forceTransform = null) + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, IObservable>? forceTransform = null) where TDestination : notnull where TSource : notnull where TKey : notnull @@ -124,7 +138,7 @@ public static IObservable> TransformAsync transformFactory(current, key), options); + return TransformAsync(source, (current, _, key, _) => transformFactory(current, key), options); } /// @@ -138,6 +152,20 @@ public static IObservable> TransformAsync transformFactory(current, previous, key), options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + return new TransformAsync(source, transformFactory, null, null, options.MaximumConcurrency, options.TransformOnRefresh).Run(); } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs index 45c842917..70477e563 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformSafeAsync.cs @@ -55,6 +55,20 @@ public static IObservable> TransformSafeAsync transformFactory(current, key), errorHandler, forceTransform); } + /// + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + where TDestination : notnull + where TSource : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + errorHandler.ThrowArgumentNullExceptionIfNull(nameof(errorHandler)); + + return source.TransformSafeAsync((s, p, k, t) => transformFactory(s, p, k), errorHandler, forceTransform); + } + /// /// Async version of . /// Projects each item using an async factory, catching factory exceptions via a mandatory error handler. @@ -63,14 +77,14 @@ public static IObservable> TransformSafeAsyncThe type of the source items. /// The type of the key. /// The source to transform asynchronously with error handling. - /// The async function that produces a . + /// The async function that produces a . /// A that called when throws or faults. The item is skipped and the stream continues. /// An optional that forces re-transformation of matching items. /// An observable changeset of transformed items. /// Combines the async execution model of with the error-safe behavior of . /// , , or is . [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, Action> errorHandler, IObservable>? forceTransform = null) where TDestination : notnull where TSource : notnull where TKey : notnull @@ -112,13 +126,24 @@ public static IObservable> TransformSafeAsync transformFactory(current, key), errorHandler, options); } - /// + /// /// This overload accepts to control concurrency and Refresh handling. [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, Task> transformFactory, Action> errorHandler, TransformAsyncOptions options) where TDestination : notnull where TSource : notnull where TKey : notnull + { + return source.TransformSafeAsync((current, previous, key, cancel) => transformFactory(current, previous, key), errorHandler, options); + } + + /// + /// This overload accepts to control concurrency and Refresh handling. + [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformSafeAsync(this IObservable> source, Func, TKey, CancellationToken, Task> transformFactory, Action> errorHandler, TransformAsyncOptions options) + where TDestination : notnull + where TSource : notnull + where TKey : notnull { source.ThrowArgumentNullExceptionIfNull(nameof(source)); transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); diff --git a/src/DynamicData/List/Internal/TransformAsync.cs b/src/DynamicData/List/Internal/TransformAsync.cs index b43edc030..31f45b1a4 100644 --- a/src/DynamicData/List/Internal/TransformAsync.cs +++ b/src/DynamicData/List/Internal/TransformAsync.cs @@ -10,23 +10,23 @@ internal sealed class TransformAsync where TSource : notnull where TDestination : notnull { - private readonly Func, int, Task.TransformedItemContainer>> _containerFactory; + private readonly Func, int, CancellationToken, Task.TransformedItemContainer>> _containerFactory; private readonly IObservable> _source; private readonly bool _transformOnRefresh; public TransformAsync( IObservable> source, - Func, int, Task> factory, + Func, int, CancellationToken, Task> factory, bool transformOnRefresh) { factory.ThrowArgumentNullExceptionIfNull(nameof(factory)); _source = source ?? throw new ArgumentNullException(nameof(source)); _transformOnRefresh = transformOnRefresh; - _containerFactory = async (item, prev, index) => + _containerFactory = async (item, prev, index, cancel) => { - var destination = await factory(item, prev, index).ConfigureAwait(false); + var destination = await factory(item, prev, index, cancel).ConfigureAwait(false); return new Transformer.TransformedItemContainer(item, destination); }; } @@ -35,33 +35,59 @@ public TransformAsync( private IObservable> RunImpl() { - var state = new ChangeAwareList.TransformedItemContainer>(); - var asyncLock = new SemaphoreSlim(1, 1); + return Observable.Using( + () => new SemaphoreSlim(1, 1), + asyncLock => + { + var state = new ChangeAwareList.TransformedItemContainer>(); - return _source.Select(async changes => - { - try - { - await asyncLock.WaitAsync(); - await Transform(state, changes); - return state; - } - finally + return _source.Select(changes => Observable.FromAsync(async cancel => + { + // NOTE: lock outside of the try to avoid releasing another scope's lock if the token is canceled before we acquire the lock. + try + { + await asyncLock.WaitAsync(cancel); + } + catch (Exception e) when (e is ObjectDisposedException) + { + return Optional.None.TransformedItemContainer>>(); + } + + try + { + await Transform(state, changes, cancel); + return Optional.Some(state); + } + finally + { + try + { + // token is canceled when outer stream is disposed which is attached to the lock's lifetime. + if (!cancel.IsCancellationRequested) + { + asyncLock.Release(); + } + } + catch (ObjectDisposedException) + { + // outer stream was disposed during inner transform. + } + } + })) + .Concat() + .SelectValues() + .Select(transformed => { - asyncLock.Release(); - } - }) - .Concat() - .Select(transformed => - { - var changed = transformed.CaptureChanges(); - return changed.Transform(container => container.Destination); + var changed = transformed.CaptureChanges(); + return changed.Transform(container => container.Destination); + }); }); } private async Task Transform( ChangeAwareList.TransformedItemContainer> transformed, - IChangeSet changes) + IChangeSet changes, + CancellationToken cancel) { changes.ThrowArgumentNullExceptionIfNull(nameof(changes)); @@ -78,7 +104,8 @@ private async Task Transform( await _containerFactory( item.Item.Current, Optional.None, - transformed.Count).ConfigureAwait(false); + transformed.Count, + cancel).ConfigureAwait(false); transformed.Add(container); } else @@ -87,7 +114,8 @@ await _containerFactory( await _containerFactory( item.Item.Current, Optional.None, - change.CurrentIndex).ConfigureAwait(false); + change.CurrentIndex, + cancel).ConfigureAwait(false); transformed.Insert(change.CurrentIndex, container); } @@ -97,7 +125,7 @@ await _containerFactory( case ListChangeReason.AddRange: { var startIndex = item.Range.Index < 0 ? transformed.Count : item.Range.Index; - var tasks = item.Range.Select((t, idx) => _containerFactory(t, Optional.None, idx + startIndex)); + var tasks = item.Range.Select((t, idx) => _containerFactory(t, Optional.None, idx + startIndex, cancel)); var containers = await Task.WhenAll(tasks).ConfigureAwait(false); transformed.AddOrInsertRange(containers, item.Range.Index); break; @@ -109,7 +137,7 @@ await _containerFactory( if (_transformOnRefresh) { Optional previous = transformed[change.CurrentIndex].Destination; - var container = await _containerFactory(change.Current, previous, change.CurrentIndex) + var container = await _containerFactory(change.Current, previous, change.CurrentIndex, cancel) .ConfigureAwait(false); transformed[change.CurrentIndex] = container; } @@ -128,12 +156,12 @@ await _containerFactory( Optional previous = transformed[change.PreviousIndex].Destination; if (change.CurrentIndex == change.PreviousIndex) { - transformed[change.CurrentIndex] = await _containerFactory(change.Current, previous, change.CurrentIndex); + transformed[change.CurrentIndex] = await _containerFactory(change.Current, previous, change.CurrentIndex, cancel); } else { transformed.RemoveAt(change.PreviousIndex); - transformed.Insert(change.CurrentIndex, await _containerFactory(change.Current, Optional.None, change.CurrentIndex)); + transformed.Insert(change.CurrentIndex, await _containerFactory(change.Current, Optional.None, change.CurrentIndex, cancel)); } break; diff --git a/src/DynamicData/List/ObservableListEx.TransformAsync.cs b/src/DynamicData/List/ObservableListEx.TransformAsync.cs index 998eb1419..d130fa3b9 100644 --- a/src/DynamicData/List/ObservableListEx.TransformAsync.cs +++ b/src/DynamicData/List/ObservableListEx.TransformAsync.cs @@ -102,7 +102,7 @@ public static IObservable> TransformAsync /// - /// Async transform overload receiving the source item, previously transformed value, and index. This is the terminal overload that all other TransformAsync overloads delegate to. + /// Async transform overload receiving the source item, previously transformed value, and index. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] public static IObservable> TransformAsync( @@ -115,6 +115,24 @@ public static IObservable> TransformAsync(source, (t, d, i, _) => transformFactory(t, d, i), transformOnRefresh).Run(); + } + + /// + /// + /// Async transform overload receiving the source item, previously transformed value, index, and CancellationToken attached to the underlying subscription. This is the terminal overload that all other TransformAsync overloads delegate to. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func, int, CancellationToken, Task> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + return new TransformAsync(source, transformFactory, transformOnRefresh).Run(); } }