From f80163c9de125a8d05f200c174e8078821ad1593 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 22:59:23 -0700 Subject: [PATCH 1/3] 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. --- .../Cache/ObservableCacheEx.Adapt.cs | 68 + .../Cache/ObservableCacheEx.AutoRefresh.cs | 129 + .../Cache/ObservableCacheEx.Batch.cs | 133 + .../Cache/ObservableCacheEx.Bind.cs | 372 + .../Cache/ObservableCacheEx.ChangeStream.cs | 207 + .../Cache/ObservableCacheEx.Combinators.cs | 548 ++ .../Cache/ObservableCacheEx.Conversions.cs | 276 + .../Cache/ObservableCacheEx.Edit.cs | 571 ++ .../Cache/ObservableCacheEx.Expiration.cs | 219 + .../Cache/ObservableCacheEx.Filter.cs | 450 ++ .../Cache/ObservableCacheEx.Group.cs | 333 + .../Cache/ObservableCacheEx.Joins.cs | 660 ++ .../Cache/ObservableCacheEx.Lifecycle.cs | 246 + .../Cache/ObservableCacheEx.Merge.cs | 557 ++ .../ObservableCacheEx.MergeManyChangeSets.cs | 436 ++ .../Cache/ObservableCacheEx.Notifications.cs | 252 + .../Cache/ObservableCacheEx.Populate.cs | 131 + .../ObservableCacheEx.PropertyChanged.cs | 217 + .../Cache/ObservableCacheEx.Query.cs | 263 + .../Cache/ObservableCacheEx.Sort.cs | 155 + ...ObservableCacheEx.ToObservableChangeSet.cs | 162 + .../Cache/ObservableCacheEx.Transform.cs | 520 ++ .../Cache/ObservableCacheEx.TransformMany.cs | 299 + .../Cache/ObservableCacheEx.TransformSafe.cs | 228 + src/DynamicData/Cache/ObservableCacheEx.cs | 6833 ----------------- 25 files changed, 7432 insertions(+), 6833 deletions(-) create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Adapt.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Batch.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Bind.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ChangeStream.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Combinators.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Conversions.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Edit.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Expiration.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Filter.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Group.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Joins.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Merge.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Notifications.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Populate.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.PropertyChanged.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Query.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Sort.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Transform.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs delete mode 100644 src/DynamicData/Cache/ObservableCacheEx.cs diff --git a/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs b/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs new file mode 100644 index 00000000..feb24083 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Adapt.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; + +/// +/// ObservableCache extensions for Adapt. +/// +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.AutoRefresh.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs new file mode 100644 index 00000000..3dcbda06 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.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; + +/// +/// ObservableCache extensions for AutoRefresh. +/// +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); + } + + /// + /// 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 00000000..b45372d0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Batch.cs @@ -0,0 +1,133 @@ +// 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; + +/// +/// ObservableCache extensions for Batch and BatchIf. +/// +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(); + } + + /// + /// 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 00000000..f5b038aa --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Bind.cs @@ -0,0 +1,372 @@ +// 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; + +/// +/// ObservableCache extensions for Bind. +/// +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 + + /// + /// 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.ChangeStream.cs b/src/DynamicData/Cache/ObservableCacheEx.ChangeStream.cs new file mode 100644 index 00000000..bce5b1a0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ChangeStream.cs @@ -0,0 +1,207 @@ +// 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; + +/// +/// ObservableCache extensions for changeset stream lifecycle helpers. +/// +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); + }); + + /// + /// 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(); + } + + /// + /// 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); + } + + /// + /// 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 }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs b/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs new file mode 100644 index 00000000..0a8f8457 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs @@ -0,0 +1,548 @@ +// 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; + +/// +/// ObservableCache extensions for set-style combinators (And, Or, Xor, Except). +/// +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); + } + + /// + /// 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); + } + + /// + /// 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); + } + + /// + /// 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; + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Conversions.cs b/src/DynamicData/Cache/ObservableCacheEx.Conversions.cs new file mode 100644 index 00000000..2afeae65 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Conversions.cs @@ -0,0 +1,276 @@ +// 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; + +/// +/// ObservableCache extensions for type and shape conversions. +/// +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); + } + + /// + /// 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); + }); + } + + /// + /// 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); + }); + } + + /// + /// 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))); + } + + /// + /// 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(); + } + + /// + /// 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.Edit.cs b/src/DynamicData/Cache/ObservableCacheEx.Edit.cs new file mode 100644 index 00000000..77f5fe6f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Edit.cs @@ -0,0 +1,571 @@ +// 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; + +/// +/// ObservableCache extensions for source cache editing helpers. +/// +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)); + } + + /// + /// 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; + } + } + }); + } + + /// + /// 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(); + } + + /// + /// 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())); + + /// + /// 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)); + } + + /// + /// 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.Expiration.cs b/src/DynamicData/Cache/ObservableCacheEx.Expiration.cs new file mode 100644 index 00000000..cd58bd82 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Expiration.cs @@ -0,0 +1,219 @@ +// 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; + +/// +/// ObservableCache extensions for ExpireAfter and LimitSizeTo. +/// +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); + + /// + /// 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.Filter.cs b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs new file mode 100644 index 00000000..2cbfa0ff --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs @@ -0,0 +1,450 @@ +// 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; + +/// +/// ObservableCache extensions for filtering and change-reason gating. +/// +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(); + } + + /// + /// 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); + } + + /// + /// 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(); + } + + /// + /// 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); + } + + /// + /// 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); + + /// + /// 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(); + } + + 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.Group.cs b/src/DynamicData/Cache/ObservableCacheEx.Group.cs new file mode 100644 index 00000000..a425a7ff --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Group.cs @@ -0,0 +1,333 @@ +// 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; + +/// +/// ObservableCache extensions for grouping operators. +/// +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); + } + + /// + /// 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(); + } + + // 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.Joins.cs b/src/DynamicData/Cache/ObservableCacheEx.Joins.cs new file mode 100644 index 00000000..fb7a1c17 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Joins.cs @@ -0,0 +1,660 @@ +// 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; + +/// +/// ObservableCache extensions for join operators (FullJoin, InnerJoin, LeftJoin, RightJoin). +/// +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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs b/src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs new file mode 100644 index 00000000..f2f3fc4f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs @@ -0,0 +1,246 @@ +// 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; + +/// +/// ObservableCache extensions for subscription lifecycle and disposal. +/// +public static partial class ObservableCacheEx +{ + #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 + + /// + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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)); + } + + /// + /// 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(); + + /// + /// 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.Merge.cs b/src/DynamicData/Cache/ObservableCacheEx.Merge.cs new file mode 100644 index 00000000..789d9fa9 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Merge.cs @@ -0,0 +1,557 @@ +// 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; + +/// +/// ObservableCache extensions for MergeMany, MergeChangeSets, MergeManyItems, and Switch. +/// +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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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.MergeManyChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs new file mode 100644 index 00000000..52a47902 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs @@ -0,0 +1,436 @@ +// 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; + +/// +/// ObservableCache extensions for MergeManyChangeSets. +/// +public static partial class ObservableCacheEx +{ + private const bool DefaultResortOnSourceRefresh = true; + + /// + /// 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); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs b/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs new file mode 100644 index 00000000..4e11c1d1 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs @@ -0,0 +1,252 @@ +// 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; + +/// +/// ObservableCache extensions for per-item change-reason notifications. +/// +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)); + + /// + /// 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)); + + 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.Populate.cs b/src/DynamicData/Cache/ObservableCacheEx.Populate.cs new file mode 100644 index 00000000..1bfd7cfc --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Populate.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; + +/// +/// ObservableCache extensions for populating caches. +/// +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); + } + + /// + /// 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.PropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.PropertyChanged.cs new file mode 100644 index 00000000..15e4b006 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.PropertyChanged.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.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; + +/// +/// ObservableCache extensions for property-change observation. +/// +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)); + } + + /// + /// 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)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Query.cs b/src/DynamicData/Cache/ObservableCacheEx.Query.cs new file mode 100644 index 00000000..3c483e17 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Query.cs @@ -0,0 +1,263 @@ +// 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; + +/// +/// ObservableCache extensions for querying and snapshot collection projection. +/// +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)); + } + + /// + /// 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(); + } + + /// + /// 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)); + + /// + /// 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); + }); + + /// + /// 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))); + } + + 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.Sort.cs b/src/DynamicData/Cache/ObservableCacheEx.Sort.cs new file mode 100644 index 00000000..4f6a2dc7 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Sort.cs @@ -0,0 +1,155 @@ +// 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; + +/// +/// ObservableCache extensions for Sort. +/// +public static partial class ObservableCacheEx +{ + private const int DefaultSortResetThreshold = 100; + + /// + /// 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); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs b/src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs new file mode 100644 index 00000000..73f2271f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ToObservableChangeSet.cs @@ -0,0 +1,162 @@ +// 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; + +/// +/// ObservableCache extensions for converting plain observables into changeset streams. +/// +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); + } + + /// + /// 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); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Transform.cs b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs new file mode 100644 index 00000000..9f2b07a4 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs @@ -0,0 +1,520 @@ +// 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; + +/// +/// ObservableCache extensions for Transform, TransformAsync, TransformImmutable, TransformOnObservable, TransformWithInlineUpdate, and TransformToTree. +/// +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()); + } + + /// + /// 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(); + } + + /// + /// 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)); + } + + /// + /// 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(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs new file mode 100644 index 00000000..08895a56 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformMany.cs @@ -0,0 +1,299 @@ +// 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; + +/// +/// ObservableCache extensions for TransformMany, TransformManyAsync, and TransformManySafeAsync. +/// +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(); + + /// + /// 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); + + 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.TransformSafe.cs b/src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs new file mode 100644 index 00000000..084e3068 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.TransformSafe.cs @@ -0,0 +1,228 @@ +// 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; + +/// +/// ObservableCache extensions for TransformSafe and TransformSafeAsync. +/// +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()); + } + + /// + /// 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.cs b/src/DynamicData/Cache/ObservableCacheEx.cs deleted file mode 100644 index 2f91f6a8..00000000 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ /dev/null @@ -1,6833 +0,0 @@ -// 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 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(); -} From 2a38ed08589a058af9b0229f11dd3aaa20917ca8 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Tue, 26 May 2026 07:30:01 -0700 Subject: [PATCH 2/3] 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. --- .../Cache/ObservableCacheEx.Adapt.cs | 69 + .../Cache/ObservableCacheEx.AutoRefresh.cs | 130 + .../Cache/ObservableCacheEx.Batch.cs | 134 + .../Cache/ObservableCacheEx.Bind.cs | 367 + .../Cache/ObservableCacheEx.ChangeStream.cs | 208 + .../Cache/ObservableCacheEx.Combinators.cs | 549 ++ .../Cache/ObservableCacheEx.Conversions.cs | 277 + .../Cache/ObservableCacheEx.Edit.cs | 572 ++ .../Cache/ObservableCacheEx.Expiration.cs | 220 + .../Cache/ObservableCacheEx.Filter.cs | 451 ++ .../Cache/ObservableCacheEx.Group.cs | 334 + .../Cache/ObservableCacheEx.Joins.cs | 661 ++ .../Cache/ObservableCacheEx.Lifecycle.cs | 352 + .../Cache/ObservableCacheEx.Merge.cs | 968 +++ .../Cache/ObservableCacheEx.Notifications.cs | 253 + .../ObservableCacheEx.PropertyChanged.cs | 218 + .../Cache/ObservableCacheEx.Query.cs | 402 + .../Cache/ObservableCacheEx.Sort.cs | 156 + .../Cache/ObservableCacheEx.Transform.cs | 996 +++ src/DynamicData/Cache/ObservableCacheEx.cs | 6830 ----------------- 20 files changed, 7317 insertions(+), 6830 deletions(-) create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Adapt.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Batch.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Bind.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.ChangeStream.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Combinators.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Conversions.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Edit.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Expiration.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Filter.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Group.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Joins.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Merge.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Notifications.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.PropertyChanged.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Query.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Sort.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.Transform.cs delete mode 100644 src/DynamicData/Cache/ObservableCacheEx.cs diff --git a/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs b/src/DynamicData/Cache/ObservableCacheEx.Adapt.cs new file mode 100644 index 00000000..1e895a26 --- /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; + +/// +/// ObservableCache extensions for Adapt. +/// +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.AutoRefresh.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs new file mode 100644 index 00000000..4633ce84 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs @@ -0,0 +1,130 @@ +// 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; + +/// +/// ObservableCache extensions for AutoRefresh. +/// +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); + } + + /// + /// 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 00000000..316933b0 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Batch.cs @@ -0,0 +1,134 @@ +// 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; + +/// +/// ObservableCache extensions for Batch and BatchIf. +/// +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(); + } + + /// + /// 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 00000000..1d94d768 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Bind.cs @@ -0,0 +1,367 @@ +// 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; + +/// +/// ObservableCache extensions for Bind. +/// +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 => + source.SynchronizeSafe(InternalEx.NewLock()).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 => + source.SynchronizeSafe(InternalEx.NewLock()).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 + + /// + /// 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.ChangeStream.cs b/src/DynamicData/Cache/ObservableCacheEx.ChangeStream.cs new file mode 100644 index 00000000..f045dcca --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.ChangeStream.cs @@ -0,0 +1,208 @@ +// 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; + +/// +/// ObservableCache extensions for changeset stream lifecycle helpers. +/// +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); + }); + + /// + /// 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(); + } + + /// + /// 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); + } + + /// + /// 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 }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs b/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs new file mode 100644 index 00000000..dd0c02a3 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs @@ -0,0 +1,549 @@ +// 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; + +/// +/// ObservableCache extensions for set-style combinators (And, Or, Xor, Except). +/// +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); + } + + /// + /// 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); + } + + /// + /// 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); + } + + /// + /// 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; + }); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Conversions.cs b/src/DynamicData/Cache/ObservableCacheEx.Conversions.cs new file mode 100644 index 00000000..bbad7072 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Conversions.cs @@ -0,0 +1,277 @@ +// 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; + +/// +/// ObservableCache extensions for type and shape conversions. +/// +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); + } + + /// + /// 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); + }); + } + + /// + /// 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); + }); + } + + /// + /// 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))); + } + + /// + /// 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(); + } + + /// + /// 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.Edit.cs b/src/DynamicData/Cache/ObservableCacheEx.Edit.cs new file mode 100644 index 00000000..a628ac5f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Edit.cs @@ -0,0 +1,572 @@ +// 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; + +/// +/// ObservableCache extensions for source cache editing helpers. +/// +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)); + } + + /// + /// 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; + } + } + }); + } + + /// + /// 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(); + } + + /// + /// 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())); + + /// + /// 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)); + } + + /// + /// 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.Expiration.cs b/src/DynamicData/Cache/ObservableCacheEx.Expiration.cs new file mode 100644 index 00000000..8e1185e3 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Expiration.cs @@ -0,0 +1,220 @@ +// 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; + +/// +/// ObservableCache extensions for ExpireAfter and LimitSizeTo. +/// +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); + + /// + /// 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.Filter.cs b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs new file mode 100644 index 00000000..316c81c9 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs @@ -0,0 +1,451 @@ +// 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; + +/// +/// ObservableCache extensions for filtering and change-reason gating. +/// +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(); + } + + /// + /// 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); + } + + /// + /// 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(); + } + + /// + /// 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); + } + + /// + /// 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); + + /// + /// 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(); + } + + 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.Group.cs b/src/DynamicData/Cache/ObservableCacheEx.Group.cs new file mode 100644 index 00000000..f2fd7301 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Group.cs @@ -0,0 +1,334 @@ +// 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; + +/// +/// ObservableCache extensions for grouping operators. +/// +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); + } + + /// + /// 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(); + } + + // 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.Joins.cs b/src/DynamicData/Cache/ObservableCacheEx.Joins.cs new file mode 100644 index 00000000..cf05552b --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Joins.cs @@ -0,0 +1,661 @@ +// 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; + +/// +/// ObservableCache extensions for join operators (FullJoin, InnerJoin, LeftJoin, RightJoin). +/// +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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs b/src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs new file mode 100644 index 00000000..8d3cca7f --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Lifecycle.cs @@ -0,0 +1,352 @@ +// 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; + +/// +/// ObservableCache extensions for subscription lifecycle, disposal, and population. +/// +public static partial class ObservableCacheEx +{ + #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 + + /// + /// + /// 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(); + } + + /// + /// 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(); + } + + /// + /// 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)); + } + + /// + /// 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(); + + /// + /// 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))); + } + + /// + /// 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.Merge.cs b/src/DynamicData/Cache/ObservableCacheEx.Merge.cs new file mode 100644 index 00000000..327895ff --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Merge.cs @@ -0,0 +1,968 @@ +// 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; + +/// +/// ObservableCache extensions for MergeMany, MergeChangeSets, MergeManyChangeSets, MergeManyItems, and Switch. +/// +public static partial class ObservableCacheEx +{ + private const bool DefaultResortOnSourceRefresh = true; + + /// + /// 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(); + } + + /// + /// 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.Notifications.cs b/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs new file mode 100644 index 00000000..d3e647c9 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs @@ -0,0 +1,253 @@ +// 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; + +/// +/// ObservableCache extensions for per-item change-reason notifications. +/// +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)); + + /// + /// 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)); + + 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.PropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.PropertyChanged.cs new file mode 100644 index 00000000..c8e5f1d8 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.PropertyChanged.cs @@ -0,0 +1,218 @@ +// 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; + +/// +/// ObservableCache extensions for property-change observation. +/// +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)); + } + + /// + /// 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)); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Query.cs b/src/DynamicData/Cache/ObservableCacheEx.Query.cs new file mode 100644 index 00000000..cd00eccc --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Query.cs @@ -0,0 +1,402 @@ +// 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; + +/// +/// ObservableCache extensions for querying, snapshot collection projection, and conversion to changeset streams. +/// +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)); + } + + /// + /// 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(); + } + + /// + /// 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) + { + 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); + } + + /// + /// 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); + }); + + /// + /// 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))); + } + + 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.Sort.cs b/src/DynamicData/Cache/ObservableCacheEx.Sort.cs new file mode 100644 index 00000000..d00e8950 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Sort.cs @@ -0,0 +1,156 @@ +// 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; + +/// +/// ObservableCache extensions for Sort. +/// +public static partial class ObservableCacheEx +{ + private const int DefaultSortResetThreshold = 100; + + /// + /// 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); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.Transform.cs b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs new file mode 100644 index 00000000..784b3662 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs @@ -0,0 +1,996 @@ +// 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; + +/// +/// ObservableCache extensions for the Transform family (Transform, TransformAsync, TransformSafe, TransformMany, and related overloads). +/// +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()); + } + + /// + /// 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(); + } + + 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.cs b/src/DynamicData/Cache/ObservableCacheEx.cs deleted file mode 100644 index 1c04772a..00000000 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ /dev/null @@ -1,6830 +0,0 @@ -// 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 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 => - source.SynchronizeSafe(InternalEx.NewLock()).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 => - source.SynchronizeSafe(InternalEx.NewLock()).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) - { - 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); - } - - /// - /// 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(); -} From 87759e8a351963ba6a0f0aeec177fc5d59943c7b Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Tue, 26 May 2026 07:32:19 -0700 Subject: [PATCH 3/3] 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. --- .../Cache/ObservableCacheEx.Combinators.cs | 234 +++++++++--------- .../Cache/ObservableCacheEx.Filter.cs | 32 +-- .../Cache/ObservableCacheEx.Group.cs | 12 +- .../Cache/ObservableCacheEx.Merge.cs | 112 ++++----- .../Cache/ObservableCacheEx.Notifications.cs | 46 ++-- .../Cache/ObservableCacheEx.Query.cs | 10 +- .../Cache/ObservableCacheEx.Transform.cs | 38 +-- 7 files changed, 242 insertions(+), 242 deletions(-) diff --git a/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs b/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs index dd0c02a3..2ab95df5 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Combinators.cs @@ -119,6 +119,123 @@ public static IObservable> And(this IOb return sources.Combine(CombineOperator.And); } + 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; + }); + } + /// /// 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. @@ -429,121 +546,4 @@ public static IObservable> Xor(this IOb 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; - }); - } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.Filter.cs b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs index 316c81c9..a209c14a 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Filter.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Filter.cs @@ -283,6 +283,22 @@ public static IObservable> FilterOnObservable filterFactory(obj), buffer, scheduler); } + 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; + }); + /// /// Ignores updates when the update is the same reference. /// @@ -432,20 +448,4 @@ public static IObservable> WhereReasonsAreNot new ChangeSet(updates.Where(u => !hashed.Contains(u.Reason)))).NotEmpty(); } - - 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.Group.cs b/src/DynamicData/Cache/ObservableCacheEx.Group.cs index f2fd7301..40974602 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Group.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Group.cs @@ -25,6 +25,12 @@ namespace DynamicData; /// 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); + /// /// 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. @@ -325,10 +331,4 @@ public static IObservable> Gr return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); } - - // 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.Merge.cs b/src/DynamicData/Cache/ObservableCacheEx.Merge.cs index 327895ff..57537f29 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Merge.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Merge.cs @@ -27,62 +27,6 @@ public static partial class ObservableCacheEx { private const bool DefaultResortOnSourceRefresh = true; - /// - /// 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. @@ -490,6 +434,62 @@ public static IObservable> MergeChangeSets(source, equalityComparer, comparer, completable, scheduler).Run(); } + /// + /// 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(); + } + /// /// 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. diff --git a/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs b/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs index d3e647c9..6a2aebb8 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Notifications.cs @@ -25,6 +25,29 @@ namespace DynamicData; /// 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)); + /// /// Callback for each item as and when it is being added to the stream. /// @@ -227,27 +250,4 @@ public static IObservable> OnItemUpdated source.OnItemUpdated((cur, prev, _) => updateAction(cur, prev)); - - 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.Query.cs b/src/DynamicData/Cache/ObservableCacheEx.Query.cs index cd00eccc..12a15149 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Query.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Query.cs @@ -299,6 +299,11 @@ public static IObservable> ToSortedCollection(items); }); + 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(); + /// /// 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. @@ -394,9 +399,4 @@ public static IObservable TrueForAny(this IObservab return source.TrueFor(observableSelector, items => items.Any(o => o.LatestValue.HasValue && equalityCondition(o.LatestValue.Value))); } - - 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.Transform.cs b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs index 784b3662..62907ab9 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.Transform.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.Transform.cs @@ -25,6 +25,25 @@ namespace DynamicData; /// 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(); + /// /// This overload accepts a bool transformOnRefresh flag. When , Refresh changes cause re-transformation (emitted as Update). The factory receives only the current item. /// @@ -974,23 +993,4 @@ public static IObservable> TransformWithInlineUpd return new TransformWithInlineUpdate(source, transformFactory, updateAction, errorHandler, transformOnRefresh).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(); }