From 2401dab36db373fc9a41d07f7dd5d4e355ef8f08 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 25 May 2026 23:06:16 -0700 Subject: [PATCH 1/2] Break ObservableListEx.cs into per-family partial classes Splits the 2900-line ObservableListEx.cs into 17 smaller partial-class files grouped by operator family. Each method (and all of its overloads) lives in exactly one file. The class declaration is changed to partial; no code, comments, or XML documentation is added, removed, or otherwise modified. All 2218 tests pass. --- .../List/ObservableListEx.Adapt.cs | 60 + .../List/ObservableListEx.AutoRefresh.cs | 139 + src/DynamicData/List/ObservableListEx.Bind.cs | 151 + .../List/ObservableListEx.ChangeStream.cs | 209 ++ .../List/ObservableListEx.Combinators.cs | 355 ++ .../List/ObservableListEx.Conversions.cs | 295 ++ .../List/ObservableListEx.Expiration.cs | 125 + .../List/ObservableListEx.Filter.cs | 351 ++ .../List/ObservableListEx.Group.cs | 160 + .../List/ObservableListEx.Lifecycle.cs | 116 + .../List/ObservableListEx.Merge.cs | 415 +++ .../List/ObservableListEx.Notifications.cs | 119 + .../List/ObservableListEx.Pagination.cs | 80 + .../List/ObservableListEx.PropertyChanged.cs | 103 + .../List/ObservableListEx.Query.cs | 279 ++ src/DynamicData/List/ObservableListEx.Sort.cs | 107 + .../List/ObservableListEx.Transform.cs | 265 ++ src/DynamicData/List/ObservableListEx.cs | 2929 ----------------- 18 files changed, 3329 insertions(+), 2929 deletions(-) create mode 100644 src/DynamicData/List/ObservableListEx.Adapt.cs create mode 100644 src/DynamicData/List/ObservableListEx.AutoRefresh.cs create mode 100644 src/DynamicData/List/ObservableListEx.Bind.cs create mode 100644 src/DynamicData/List/ObservableListEx.ChangeStream.cs create mode 100644 src/DynamicData/List/ObservableListEx.Combinators.cs create mode 100644 src/DynamicData/List/ObservableListEx.Conversions.cs create mode 100644 src/DynamicData/List/ObservableListEx.Expiration.cs create mode 100644 src/DynamicData/List/ObservableListEx.Filter.cs create mode 100644 src/DynamicData/List/ObservableListEx.Group.cs create mode 100644 src/DynamicData/List/ObservableListEx.Lifecycle.cs create mode 100644 src/DynamicData/List/ObservableListEx.Merge.cs create mode 100644 src/DynamicData/List/ObservableListEx.Notifications.cs create mode 100644 src/DynamicData/List/ObservableListEx.Pagination.cs create mode 100644 src/DynamicData/List/ObservableListEx.PropertyChanged.cs create mode 100644 src/DynamicData/List/ObservableListEx.Query.cs create mode 100644 src/DynamicData/List/ObservableListEx.Sort.cs create mode 100644 src/DynamicData/List/ObservableListEx.Transform.cs delete mode 100644 src/DynamicData/List/ObservableListEx.cs diff --git a/src/DynamicData/List/ObservableListEx.Adapt.cs b/src/DynamicData/List/ObservableListEx.Adapt.cs new file mode 100644 index 00000000..a8b18073 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Adapt.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for Adapt. +/// +public static partial class ObservableListEx +{ + /// + /// Injects a side effect into a changeset stream via an . + /// The adaptor's Adapt method is invoked for each changeset before it is forwarded downstream unchanged. + /// + /// The type of items in the list. + /// The source to observe and adapt. + /// The adaptor whose Adapt method is invoked for each changeset. + /// A list changeset stream identical to the source, with the adaptor side effect applied. + /// or is . + /// + /// + /// This is the primary extension point for custom UI binding adaptors (e.g., + /// delegates to this operator). If the adaptor throws, the exception propagates downstream as OnError. + /// + /// + /// + public static IObservable> Adapt(this IObservable> source, IChangeSetAdaptor adaptor) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); + + return Observable.Create>( + observer => + { + var locker = InternalEx.NewLock(); + return source.Synchronize(locker).Select( + changes => + { + adaptor.Adapt(changes); + return changes; + }).SubscribeSafe(observer); + }); + } +} diff --git a/src/DynamicData/List/ObservableListEx.AutoRefresh.cs b/src/DynamicData/List/ObservableListEx.AutoRefresh.cs new file mode 100644 index 00000000..828289ff --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.AutoRefresh.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for AutoRefresh. +/// +public static partial class ObservableListEx +{ + /// + /// Monitors all properties on each item (via ) and emits Refresh + /// changes when any property changes, causing downstream operators to re-evaluate. + /// + /// The type of items, which must implement . + /// The source to monitor for property-driven refresh signals. + /// An optional buffer duration to batch multiple refresh signals into a single changeset. + /// An optional throttle applied to each item's property change notifications. + /// The scheduler for throttle and buffer timing. Defaults to . + /// A list changeset stream with additional Refresh changes injected when properties change. + /// is . + /// + /// + /// Wraps using WhenAnyPropertyChanged() as the re-evaluator. + /// Pair with or + /// to get reactive re-evaluation on property changes. + /// + /// + /// EventBehavior + /// Add/AddRangeSubscribes to PropertyChanged on each new item. The original change is forwarded. + /// ReplaceUnsubscribes from the old item, subscribes to the new. The original change is forwarded. + /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. + /// Moved/RefreshForwarded unchanged. + /// Property changesA Refresh change is emitted for the item whose property changed. + /// + /// Worth noting: Each item generates a subscription. For large lists with frequent property changes, use and to reduce churn. + /// + /// + /// + /// + /// + public static IObservable> AutoRefresh(this IObservable> source, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.AutoRefreshOnObservable( + t => + { + if (propertyChangeThrottle is null) + { + return t.WhenAnyPropertyChanged(); + } + + return t.WhenAnyPropertyChanged().Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); + }, + changeSetBuffer, + scheduler); + } + + /// + /// Monitors a single property (selected by ) on each item via + /// and emits Refresh changes when that property changes, causing downstream operators to re-evaluate. More efficient than + /// the all-properties overload when only one property (of type ) affects downstream behavior. + /// + /// + public static IObservable> AutoRefresh(this IObservable> source, Expression> propertyAccessor, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + return source.AutoRefreshOnObservable( + t => + { + if (propertyChangeThrottle is null) + { + return t.WhenPropertyChanged(propertyAccessor, false); + } + + return t.WhenPropertyChanged(propertyAccessor, false).Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); + }, + changeSetBuffer, + scheduler); + } + + /// + /// Monitors each item with a custom observable and emits Refresh changes whenever that observable fires, + /// causing downstream operators (Filter, Sort, Group) to re-evaluate. + /// + /// The type of items in the list. + /// The type emitted by the re-evaluator observable (value is ignored). + /// The source to monitor for observable-driven refresh signals. + /// A factory that, given an item, returns an observable whose emissions trigger a Refresh for that item. + /// An optional buffer duration to batch refresh signals into a single changeset. + /// The for buffering. + /// A list changeset stream with additional Refresh changes injected when per-item observables fire. + /// or is . + /// + /// + /// This is the general-purpose refresh mechanism. + /// is a convenience wrapper that uses WhenAnyPropertyChanged() as the re-evaluator. + /// + /// + /// EventBehavior + /// Add/AddRangeSubscribes to the re-evaluator observable for each new item. The original change is forwarded. + /// ReplaceUnsubscribes from the old item's observable, subscribes to the new. The original change is forwarded. + /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. + /// Moved/RefreshForwarded unchanged. + /// Re-evaluator firesThe item's current index is looked up and a Refresh change is emitted. + /// + /// + /// + /// + /// + public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); + + return new AutoRefresh(source, reevaluator, changeSetBuffer, scheduler).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Bind.cs b/src/DynamicData/List/ObservableListEx.Bind.cs new file mode 100644 index 00000000..e5c9a031 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Bind.cs @@ -0,0 +1,151 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for Bind. +/// +public static partial class ObservableListEx +{ + /// + /// Applies changeset mutations to a target for UI data binding. + /// + /// The type of items in the list. + /// The source to bind to a collection. + /// The target collection to keep in sync. + /// When a changeset exceeds this many changes, the collection is reset instead of applying individual changes. + /// A continuation of the source changeset stream (allows further chaining). + /// or is . + /// + /// + /// Delegates to with an internal collection adaptor. + /// Each changeset is applied to the target collection on the calling thread. For UI binding, ensure the source is + /// observed on the UI thread (e.g., via ObserveOn). + /// + /// + /// EventBehavior + /// AddItem inserted at the specified index in the target collection. + /// AddRangeItems inserted as a range. If the count exceeds , the collection is cleared and repopulated. + /// ReplaceItem at the specified index is replaced. + /// RemoveItem at the specified index is removed. + /// RemoveRange/ClearItems removed from the collection. + /// MovedItem is moved between positions in the collection. + /// RefreshDepends on the adaptor implementation. + /// + /// + /// + /// + /// + /// + /// + public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + + var options = resetThreshold == BindingOptions.DefaultResetThreshold + ? defaults + : defaults with { ResetThreshold = resetThreshold }; + + return source.Bind(targetCollection, options); + } + + /// + /// Binds the source changeset stream to , with fine-grained control over reset threshold and other behaviors. + /// + /// + public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, BindingOptions options) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); + + var adaptor = new ObservableCollectionAdaptor(targetCollection, options); + return source.Adapt(adaptor); + } + + /// + /// Constructs a and binds the changeset stream to it. + /// Use this overload when you need a read-only view (typically for UI binding) without managing the backing collection yourself. + /// The created collection is returned via the output parameter. + /// + /// + /// + /// + /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. + /// + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + // if user has not specified different defaults, use system wide defaults instead. + // This is a hack to retro fit system wide defaults which override the hard coded defaults above + var defaults = DynamicDataOptions.Binding; + var options = resetThreshold == BindingOptions.DefaultResetThreshold + ? defaults + : defaults with { ResetThreshold = resetThreshold }; + + return source.Bind(out readOnlyObservableCollection, options); + } + + /// + /// Constructs a and binds the changeset stream to it, + /// with fine-grained control over reset threshold and other behaviors. + /// The created collection is returned via the output parameter. + /// + /// + /// + /// + /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. + /// + public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + var target = new ObservableCollectionExtended(); + var result = new ReadOnlyObservableCollection(target); + var adaptor = new ObservableCollectionAdaptor(target, options); + readOnlyObservableCollection = result; + return source.Adapt(adaptor); + } + +#if SUPPORTS_BINDINGLIST + + /// + /// Binds the source changeset stream to a WinForms , keeping in sync. + /// + /// + public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); + + return source.Adapt(new BindingListAdaptor(bindingList, resetThreshold)); + } + +#endif +} diff --git a/src/DynamicData/List/ObservableListEx.ChangeStream.cs b/src/DynamicData/List/ObservableListEx.ChangeStream.cs new file mode 100644 index 00000000..830d3687 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.ChangeStream.cs @@ -0,0 +1,209 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for changeset stream lifecycle helpers and buffering. +/// +public static partial class ObservableListEx +{ + /// + /// + /// + /// This overload starts unpaused and has no timeout. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, IScheduler? scheduler = null) + where T : notnull => BufferIf(source, pauseIfTrueSelector, false, scheduler); + + /// + /// + /// + /// This overload allows setting the initial pause state but has no timeout. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, IScheduler? scheduler = null) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); + + return BufferIf(source, pauseIfTrueSelector, initialPauseState, null, scheduler); + } + + /// + /// + /// + /// This overload starts unpaused and accepts a timeout but not an explicit initial pause state. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, TimeSpan? timeOut, IScheduler? scheduler = null) + where T : notnull => BufferIf(source, pauseIfTrueSelector, false, timeOut, scheduler); + + /// + /// Buffers changeset notifications while a pause signal is active, then flushes all buffered changes when resumed. + /// + /// The type of items in the list. + /// The source to conditionally buffer. + /// An of that controls buffering: pauses (buffers), resumes (flushes). + /// The initial pause state. When , buffering starts immediately. + /// An optional maximum duration to keep the buffer open. After this time, the buffer is flushed regardless of pause state. + /// The for timeout scheduling. + /// A list changeset stream that buffers during pause and emits combined changesets on resume. + /// or is . + /// + /// + /// All changeset events are buffered at the changeset level (not individual changes) while paused. + /// On resume, all buffered changesets are emitted as a single combined changeset. If the buffer is empty on resume, + /// no emission occurs. + /// + /// + /// EventBehavior + /// Any (while paused)Accumulated in an internal buffer. Not emitted downstream. + /// Any (while active)Passed through immediately. + /// Pause selector emits falseAll buffered changesets are flushed downstream as one combined changeset. + /// Timeout firesAutomatically resumes and flushes the buffer. + /// OnErrorForwarded immediately (not buffered). + /// OnCompletedForwarded immediately. + /// + /// Worth noting: Each pause/resume cycle re-arms the timeout. Rapid toggling can create many small buffer windows. + /// + public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, TimeSpan? timeOut, IScheduler? scheduler = null) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); + + return new BufferIf(source, pauseIfTrueSelector, initialPauseState, timeOut, scheduler).Run(); + } + + /// + /// Buffers changesets during an initial time window, then emits a single combined changeset and passes through subsequent changes. + /// + /// The type of items in the list. + /// The source to buffer during the initial loading period. + /// The time period (measured from first emission) during which changes are buffered. + /// The for timing the buffer window. + /// A list changeset stream where the initial burst is combined into one changeset. + /// + /// + /// For a configured duration after the first emission, all changesets are buffered and combined into a single emission. + /// After this initial window, subsequent changesets pass through immediately. + /// + /// + /// + /// + public static IObservable> BufferInitial(this IObservable> source, TimeSpan initialBuffer, IScheduler? scheduler = null) + where TObject : notnull => source.DeferUntilLoaded().Publish( + shared => + { + var initial = shared.Buffer(initialBuffer, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult().Take(1); + + return initial.Concat(shared); + }); + + /// + /// Defers downstream delivery until the source emits its first changeset, then forwards all subsequent changesets. + /// + /// The type of the object. + /// The source to defer until the first changeset arrives. + /// A list changeset stream that begins emitting only after the source has produced its first changeset. + /// is . + /// + /// + /// Subscribes to the source immediately but buffers internally until the first changeset arrives, at which point it emits + /// the initial data and all subsequent changesets. This is useful when downstream consumers should not receive an empty initial state. + /// + /// + /// + /// + public static IObservable> DeferUntilLoaded(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DeferUntilLoaded(source).Run(); + } + + /// + /// + /// + /// Convenience overload that calls source.Connect().DeferUntilLoaded(). + /// + public static IObservable> DeferUntilLoaded(this IObservableList source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().DeferUntilLoaded(); + } + + /// + /// Suppresses empty changesets from the stream. Only changesets with at least one change are forwarded. + /// + /// The type of the item. + /// The source to suppress empty changesets. + /// A list changeset stream with empty changesets filtered out. + /// is . + /// + /// + public static IObservable> NotEmpty(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Where(s => s.Count != 0); + } + + /// + /// Skips the initial changeset (the snapshot emitted on subscription) and forwards all subsequent changesets. + /// Internally defers until loaded, then skips the first emission. + /// + /// The type of the object. + /// The source to skip the initial changeset. + /// A list changeset stream that omits the initial snapshot. + /// is . + /// + /// + /// Warning: This operator assumes the initial changeset is empty. If the source emits a non-empty + /// initial snapshot, those items are silently dropped while downstream consumers remain unaware of them. + /// Any later Refresh, Replace, Remove, or Moved change targeting one of those + /// dropped items will throw because the downstream collection has no record of them. Only use this against + /// a source you know starts empty (for example, a that has not yet been populated). + /// + /// + /// + /// + public static IObservable> SkipInitial(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.DeferUntilLoaded().Skip(1); + } + + /// + /// Prepends an empty changeset to the source stream. Useful for initializing downstream consumers that expect an initial emission. + /// + /// The type of item. + /// The source to prepend an empty changeset to. + /// A list changeset stream that begins with an empty changeset. + /// + /// + /// + public static IObservable> StartWithEmpty(this IObservable> source) + where T : notnull => source.StartWith(ChangeSet.Empty); +} diff --git a/src/DynamicData/List/ObservableListEx.Combinators.cs b/src/DynamicData/List/ObservableListEx.Combinators.cs new file mode 100644 index 00000000..dadc6a53 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Combinators.cs @@ -0,0 +1,355 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for set-style combinators (And, Or, Xor, Except). +/// +public static partial class ObservableListEx +{ + /// + /// Applies a logical AND (intersection) between multiple list changeset streams. + /// Only items present in ALL sources appear in the result. + /// + /// The type of items in the lists. + /// The first source to intersect. + /// The additional changeset streams to intersect with. + /// A list changeset stream containing items that exist in every source. + /// is . + /// + /// + /// Uses reference counting per item across all sources. An item appears downstream only when + /// its reference count is non-zero in ALL sources. Item identity is determined by the default equality comparer. + /// + /// + /// EventBehavior + /// Add/AddRangeThe item's reference count is incremented in its source tracker. If the item is now present in all sources, an Add is emitted. + /// ReplaceThe old item's reference count is decremented and the new item's is incremented. Depending on whether each is present in ALL sources, this emits an Add, Remove, Replace, or nothing. + /// Remove/RemoveRange/ClearThe item's reference count is decremented. If it was in the result and is no longer in all sources, a Remove is emitted. + /// RefreshForwarded as Refresh if the item is currently in the result. + /// MovedIgnored (set operations are position-independent). + /// + /// Worth noting: Item identity uses object equality, not position. Duplicate items in a single source are reference-counted independently. + /// + /// + /// + /// + /// + public static IObservable> And(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.And, others); + } + + /// + /// A of changeset streams to intersect. + /// + /// + /// This overload accepts a pre-built collection of sources instead of a params array. + /// + public static IObservable> And(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// An of changeset streams. Sources can be added or removed dynamically. + /// + /// + /// This overload supports dynamic source management: adding or removing changeset streams from the observable list triggers re-evaluation. + /// + public static IObservable> And(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// An of . Each inner list's changes are connected automatically. + /// + /// + /// This overload accepts instances directly, calling Connect() internally. + /// + public static IObservable> And(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// An of . Each inner list's changes are connected automatically. + /// + /// + /// This overload accepts instances directly, calling Connect() internally. + /// + public static IObservable> And(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.And); + + /// + /// Applies a logical set-difference (Except) between the source and other streams. + /// Items present in the first source but not in any of the are included in the result. + /// + /// The type of the item. + /// The primary from which other streams are subtracted. + /// The other changeset streams to exclude from the result. + /// A list changeset stream containing items from that are not in any of . + /// is . + /// + /// + /// Item identity is determined by the default equality comparer for . Across all sources, items are tracked + /// by reference-counted equality (not by index position). + /// The first source has a special role: only items from it can appear in the result, and only if they do not exist in any other source. + /// + /// + /// EventBehavior + /// Add/AddRange (first source)If the item does not exist in any other source, an Add is emitted. + /// Add/AddRange (other source)If the item was in the result (from first source), a Remove is emitted. + /// Remove/RemoveRange/Clear (first source)If the item was in the result, a Remove is emitted. + /// Remove/RemoveRange/Clear (other source)If the item exists in the first source and no longer in any other, an Add is emitted. + /// ReplaceTreated as a Remove of the old item plus an Add of the new item, with set logic re-evaluated. + /// MovedIgnored by the set logic (no positional semantics). + /// RefreshForwarded if the item is currently in the result set. + /// + /// Worth noting: Unlike , the first source is asymmetric: only its items can appear in the result. + /// + /// + /// + /// + /// + public static IObservable> Except(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.Except, others); + } + + /// + /// + /// + /// Static overload accepting a pre-built collection of sources. The first item in the collection is the primary source. + /// + public static IObservable> Except(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// + /// Dynamic overload: sources can be added or removed from the at runtime. The first source in the list acts as the primary. + /// + public static IObservable> Except(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// + /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Except(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// + /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Except(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Except); + + /// + /// + /// Applies a logical OR (union) between a pre-built collection of list changeset sources. Items present in any source are included. + /// + /// + public static IObservable> Or(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// Applies a logical OR (union) between the source and other list changeset streams. + /// Items present in any of the sources are included in the result, using reference-counted equality. + /// + /// The type of the item. + /// The primary source to union. + /// The other changeset streams to combine with. + /// A list changeset stream containing items that exist in at least one source. + /// is . + /// + /// + /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it first appears in any source and removed when it no longer exists in any source. + /// Moved changes are ignored by the set logic. + /// + /// + /// EventBehavior + /// Add/AddRange (any source)If the item is new to the result, an Add is emitted. Otherwise the reference count is incremented. + /// Remove/RemoveRange/Clear (any source)Reference count decremented. If count reaches zero, a Remove is emitted. + /// ReplaceOld item reference count decremented, new item reference count incremented. Add/Remove emitted as needed. + /// RefreshForwarded if the item is in the result set. + /// MovedIgnored. + /// + /// + /// + /// + /// + /// + public static IObservable> Or(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.Or, others); + } + + /// + /// + /// Dynamic OR: sources can be added or removed from the at runtime. + /// + public static IObservable> Or(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// + /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Or(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// + /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Or(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Or); + + /// + /// Applies a logical XOR (symmetric difference) between the source and other streams. + /// Items present in exactly one source are included in the result. + /// + /// The type of the item. + /// The primary source to exclusively combine. + /// The other changeset streams to combine with. + /// A list changeset stream containing items that exist in exactly one source. + /// is . + /// + /// + /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it exists in exactly one source. + /// If it appears in a second source, it is removed from the result. If it then leaves one source, + /// it re-enters the result. Moved changes are ignored. + /// + /// + /// EventBehavior + /// Add/AddRangeReference count updated. If the item is now in exactly one source, an Add is emitted. If now in two or more, a Remove is emitted. + /// Remove/RemoveRange/ClearReference count decremented. If now in exactly one source, an Add is emitted. If now in zero, a Remove is emitted. + /// ReplaceOld item reference count decremented, new item incremented, with Xor logic applied. + /// RefreshForwarded if item is in the result set. + /// MovedIgnored. + /// + /// + /// + /// + /// + /// + public static IObservable> Xor(this IObservable> source, params IObservable>[] others) + where T : notnull + { + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.Combine(CombineOperator.Xor, others); + } + + /// + /// + /// Applies a logical XOR between a pre-built collection of list changeset sources. + /// + public static IObservable> Xor(this ICollection>> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + /// + /// + /// Dynamic XOR: sources can be added or removed from the at runtime. + /// + public static IObservable> Xor(this IObservableList>> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + /// + /// + /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Xor(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + /// + /// + /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. + /// + public static IObservable> Xor(this IObservableList> sources) + where T : notnull => sources.Combine(CombineOperator.Xor); + + private static IObservable> Combine(this ICollection>> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new Combiner(sources, type).Run(); + } + + private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] others) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + if (others.Length == 0) + { + throw new ArgumentException("Must be at least one item to combine with", nameof(others)); + } + + var items = source.EnumerateOne().Union(others).ToList(); + return new Combiner(items, type).Run(); + } + + private static IObservable> Combine(this IObservableList> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); + var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(changesSetList, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); + var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(changesSetList, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList>> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new DynamicCombiner(sources, type).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Conversions.cs b/src/DynamicData/List/ObservableListEx.Conversions.cs new file mode 100644 index 00000000..2376273d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Conversions.cs @@ -0,0 +1,295 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for type and shape conversions. +/// +public static partial class ObservableListEx +{ + /// + /// Adds a key to each item in a list changeset, converting it to a cache changeset that supports all keyed DynamicData operators. + /// + /// The type of items in the list. + /// The type of the key. + /// The source to add keys to, converting to a cache changeset. + /// A function to extract a unique key from each item. + /// A cache changeset stream with keyed items. + /// or is . + /// + /// + /// All index information is dropped during conversion because cache changesets are unordered by default. + /// Use this when you need to transition from list-based pipelines to cache-based operators (Filter by key, Join, Group, etc.). + /// + /// + /// + public static IObservable> AddKey(this IObservable> source, Func keySelector) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); + + return source.Select(changes => new ChangeSet(new AddKeyEnumerator(changes, keySelector))); + } + + /// + /// Wraps a as a read-only , hiding mutation methods. + /// + /// The type of items in the list. + /// The mutable source list to wrap. + /// A read-only observable list that mirrors the source. + /// is . + public static IObservableList AsObservableList(this ISourceList source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new AnonymousObservableList(source); + } + + /// + /// Materializes a changeset stream into a read-only . + /// The list is kept in sync with the source stream for the lifetime of the subscription. + /// + /// The type of items in the list. + /// The source to materialize into a read-only list. + /// A read-only observable list reflecting the current state of the stream. + /// is . + /// + /// + /// This is the primary way to multicast a changeset pipeline. Materializing once into an , + /// then calling Connect() on the result for each downstream consumer, ensures the upstream operators are evaluated only once + /// regardless of how many subscribers consume the result. + /// + /// + /// + public static IObservableList AsObservableList(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new AnonymousObservableList(source); + } + + /// + /// Casts each item in the changeset from object to using a direct cast. + /// + /// The target type to cast to. + /// The source of object items. + /// A list changeset stream of cast items. + /// is . + /// + /// + public static IObservable> Cast(this IObservable> source) + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select(changes => changes.Transform(t => (TDestination)t)); + } + + /// + /// Transforms each item in the changeset using a conversion function. + /// + /// The source item type. + /// The destination item type. + /// The source to cast. + /// A function to convert each item from to . + /// A list changeset stream of converted items. + /// or is . + /// Use this overload when type inference requires explicit specification of both source and destination types. Alternatively, call first, then the single-type-parameter overload. + /// + /// + public static IObservable> Cast(this IObservable> source, Func conversionFactory) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); + + return source.Select(changes => changes.Transform(conversionFactory)); + } + + /// + /// Casts each item in the changeset to object. Typically used before to work around type inference limitations. + /// + /// The source item type (must be a reference type). + /// The source to cast to object. + /// A list changeset stream of object items. + /// + public static IObservable> CastToObject(this IObservable> source) + where T : class => source.Select(changes => changes.Transform(t => (object)t)); + + /// + /// Applies each changeset to the target list as a side effect, keeping it synchronized with the source. + /// + /// The type of items in the list. + /// The source to clone. + /// The target list to clone changes into. + /// A continuation of the source changeset stream. + /// is . + /// + /// Lower-level than . Uses .Clone() to apply all changeset operations directly. + /// + /// + /// + public static IObservable> Clone(this IObservable> source, IList target) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Do(target.Clone); + } + + /// + /// Convert the object using the specified conversion function. + /// This is a lighter equivalent of Transform and is designed to be used with non-disposable objects. + /// + /// The type of items in the list. + /// The type of the destination items. + /// The source to convert. + /// The conversion factory. + /// An observable which emits the change set. + [Obsolete("Prefer Cast as it is does the same thing but is semantically correct")] + public static IObservable> Convert(this IObservable> source, Func conversionFactory) + where TObject : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); + + return source.Select(changes => changes.Transform(conversionFactory)); + } + + /// + /// Flattens buffered changesets (e.g. from ) back into single changesets. + /// Empty buffers are dropped. + /// + /// The type of the item. + /// The of buffered changeset lists. + /// A list changeset stream with all buffered changes concatenated into single changesets. + /// + /// Use this after applying Observable.Buffer() to a changeset stream to re-merge the batched changesets into a single stream. + /// + /// + /// + public static IObservable> FlattenBufferResult(this IObservable>> source) + where T : notnull => source.Where(x => x.Count != 0).Select(updates => new ChangeSet(updates.SelectMany(u => u))); + + /// + /// Invokes once for every in each changeset. Range changes + /// (AddRange, RemoveRange, Clear) are delivered as a single ; they are not flattened into per-item changes. + /// The changeset is forwarded downstream unchanged. + /// + /// The type of items in the list. + /// The source to observe each change in. + /// The action invoked for each . + /// A continuation of the source changeset stream. + /// or is . + /// + /// This is a side-effect operator. It does not modify the changeset. If you need each individual item from range operations flattened out, use instead. + /// + /// EventBehavior + /// Add/Replace/Remove/Moved/RefreshCallback invoked with the (single-item change). Changeset forwarded. + /// AddRange/RemoveRange/ClearCallback invoked once with the containing the range (accessible via Range property). Changeset forwarded. + /// OnErrorIf the callback throws, the exception propagates as OnError. + /// + /// + /// + /// + /// + /// + public static IObservable> ForEachChange(this IObservable> source, Action> action) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + action.ThrowArgumentNullExceptionIfNull(nameof(action)); + + return source.Do(changes => changes.ForEach(action)); + } + + /// + /// Invokes for every individual in each changeset. + /// Range changes are flattened into individual item changes first, so the callback only receives Add, Replace, Remove, and Refresh. + /// + /// The type of items in the list. + /// The source to observe each item-level change in. + /// The action invoked for each individual item change. + /// A continuation of the source changeset stream. + /// or is . + /// + /// + /// Unlike , this operator flattens + /// AddRange, RemoveRange, and Clear into individual entries before invoking the callback. + /// + /// + /// + public static IObservable> ForEachItemChange(this IObservable> source, Action> action) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + action.ThrowArgumentNullExceptionIfNull(nameof(action)); + + return source.Do(changes => changes.Flatten().ForEach(action)); + } + + /// + /// Reference-counted materialization of the source changeset stream into an . + /// The shared list is created on the first subscriber and disposed when the last subscriber unsubscribes. + /// + /// The type of the item. + /// The source to share via reference counting. + /// A list changeset stream backed by a shared, reference-counted . + /// is . + /// + /// Equivalent to Publish().RefCount() for changeset streams. The underlying list is created lazily on first subscription. + /// + /// + public static IObservable> RefCount(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new RefCount(source).Run(); + } + + /// + /// Strips index information from all changes in the stream. + /// + /// The type of the object. + /// The source to strip index information. + /// A list changeset stream with all index values removed from changes. + /// is . + /// + /// Removes index positions from every change in each changeset. This is useful when downstream operators do not require or support index-based operations. + /// + /// + public static IObservable> RemoveIndex(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select(changes => new ChangeSet(changes.YieldWithoutIndex())); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Expiration.cs b/src/DynamicData/List/ObservableListEx.Expiration.cs new file mode 100644 index 00000000..4407c9c6 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Expiration.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for ExpireAfter, LimitSizeTo, and Top. +/// +public static partial class ObservableListEx +{ + /// + /// Automatically removes items from the list after the duration returned by . + /// Returns an observable of the items that were expired and removed. + /// + /// The type of the item. + /// The source list to apply time-based expiration to. + /// A function returning the time-to-live for each item. Return for items that should never expire. + /// An optional polling interval to batch expiry checks. If omitted, a separate timer is created for each unique expiry time. + /// The scheduler for scheduling expiry timers. Defaults to . + /// An observable that emits collections of items each time expired items are removed from the source list. + /// + /// + /// This operator acts directly on an , not on a changeset stream. It monitors items as they are added, + /// schedules their removal, and physically removes them from the source list when their time expires. + /// + /// + /// When is specified, all items due for removal are batched into a single removal at each polling tick, + /// which can improve performance when many items expire around the same time. + /// + /// Worth noting: The returned observable emits the expired items (not changesets). Subscribe to this observable to trigger the expiry mechanism; if not subscribed, no items will be removed. + /// + /// + /// + public static IObservable> ExpireAfter( + this ISourceList source, + Func timeSelector, + TimeSpan? pollingInterval = null, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ExpireAfter.Create( + source: source, + timeSelector: timeSelector, + pollingInterval: pollingInterval, + scheduler: scheduler); + + /// + /// Limits the source list to a maximum number of items using FIFO eviction. + /// When the list exceeds , the oldest items are removed. + /// Returns an observable of the items that were removed. + /// + /// The type of the item. + /// The source list to apply size limits to. + /// The maximum number of items allowed. Must be greater than zero. + /// The scheduler for scheduling size checks. Defaults to . + /// An observable that emits collections of items each time excess items are removed from the source list. + /// is . + /// is zero or negative. + /// + /// + /// This operator acts directly on an . It subscribes to the source's changes, + /// tracks insertion order using an internal Transform, and removes the oldest items when the size limit is exceeded. + /// + /// Worth noting: The returned observable emits the removed items (not changesets). Subscribe to this observable to activate the size-limiting mechanism. Removal is performed synchronously under a lock shared with the change tracking. + /// + /// + /// + public static IObservable> LimitSizeTo(this ISourceList source, int sizeLimit, IScheduler? scheduler = null) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (sizeLimit <= 0) + { + throw new ArgumentException("sizeLimit cannot be zero", nameof(sizeLimit)); + } + + var locker = InternalEx.NewLock(); + var limiter = new LimitSizeTo(source, sizeLimit, scheduler ?? GlobalConfig.DefaultScheduler, locker); + + return limiter.Run().Synchronize(locker).Do(source.RemoveMany); + } + + /// + /// Takes the first items from the source list. Implemented as Virtualise with a fixed window starting at index 0. + /// + /// The type of the item. + /// The source to take the top items. + /// The maximum number of items to include. Must be greater than zero. + /// A virtual changeset stream containing at most items from the beginning of the source. + /// is . + /// is zero or negative. + /// + /// The source should ideally be sorted before applying Top, since list order determines which items appear. + /// + /// + /// + /// + public static IObservable> Top(this IObservable> source, int numberOfItems) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + if (numberOfItems <= 0) + { + throw new ArgumentOutOfRangeException(nameof(numberOfItems), "Number of items should be greater than zero"); + } + + return source.Virtualise(Observable.Return(new VirtualRequest(0, numberOfItems))); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Filter.cs b/src/DynamicData/List/ObservableListEx.Filter.cs new file mode 100644 index 00000000..db4c9c4a --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Filter.cs @@ -0,0 +1,351 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for filtering and change-reason gating. +/// +public static partial class ObservableListEx +{ + /// + /// Extracts distinct values from source items using , with reference counting to track when values enter and leave the result set. + /// + /// The type of items in the source list. + /// The type of distinct values produced. + /// The source to extract distinct values. + /// A function that extracts the value to track from each source item. + /// A list changeset stream of distinct values. + /// or is . + /// + /// + /// Maintains an internal reference count per distinct value. A value is included when its count first exceeds zero + /// and removed when its count drops back to zero. + /// + /// + /// EventBehavior + /// Add/AddRangeValue extracted. If first occurrence, an Add is emitted. Otherwise the reference count is incremented silently. + /// ReplaceOld value's reference count decremented (removed if zero), new value's count incremented (added if first). If the value did not change, no emission. + /// Remove/RemoveRangeReference count decremented. If the count reaches zero, a Remove is emitted for that distinct value. + /// RefreshValue is re-extracted. If changed, old value decremented and new value incremented (same as Replace logic). + /// ClearAll reference counts cleared. Remove emitted for every tracked distinct value. + /// + /// + /// + public static IObservable> DistinctValues(this IObservable> source, Func valueSelector) + where TObject : notnull + where TValue : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + valueSelector.ThrowArgumentNullExceptionIfNull(nameof(valueSelector)); + + return new Distinct(source, valueSelector).Run(); + } + + /// + /// Filters items from the source list changeset stream using a static predicate. + /// Only items satisfying are included downstream. + /// + /// The type of items in the list. + /// The source to filter. + /// A predicate that determines which items are included. Items returning appear downstream; items returning are excluded. + /// A list changeset stream containing only items that satisfy . + /// Thrown when or is . + /// + /// + /// Use this overload when you need only a single predicate function for the lifetime of the subscription; + /// unlike the dynamic-predicate and state-driven overloads, the predicate function itself never changes. + /// Note that this does not mean an item's inclusion is fixed: Refresh events can re-evaluate each item against the predicate + /// and promote a previously-excluded item to included (or vice versa). + /// Item ordering is preserved. + /// + /// + /// EventBehavior + /// AddThe predicate is evaluated. If the item passes, an Add is emitted at the calculated downstream index. Otherwise dropped. + /// AddRangeEach item in the range is evaluated. Matching items are emitted as an AddRange. + /// ReplaceThe predicate is re-evaluated. Four outcomes: both pass produces Replace; new passes but old didn't produces Add; old passed but new doesn't produces Remove; neither passes is dropped. + /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. + /// RemoveRangeIncluded items in the range are emitted as individual Remove changes. + /// RefreshThe predicate is re-evaluated. If the item now passes but previously did not, an Add is emitted. If it previously passed but no longer does, a Remove is emitted. If still passes, the Refresh is forwarded. If still fails, dropped. + /// ClearAll downstream items are cleared. + /// + /// Worth noting: Refresh events trigger re-evaluation, which can promote or demote items (turning a Refresh into an Add or Remove). Pair with for property-change-driven filtering. + /// + /// + /// + /// + /// + public static IObservable> Filter( + this IObservable> source, + Func predicate) + where T : notnull + => List.Internal.Filter.Static.Create( + source: source, + predicate: predicate, + suppressEmptyChangesets: true); + + /// + /// Filters items using a dynamically changing predicate. + /// When emits a new function, all items are re-evaluated. + /// + /// The type of the item. + /// The source to filter. + /// An that emits new predicate functions. Each emission triggers a full re-evaluation of all items. + /// The that controls re-filtering behavior when the predicate changes. + /// A list changeset stream containing only items that satisfy the most recent predicate. + /// + /// + /// Each time emits, every item is re-evaluated against the new predicate. + /// + /// + /// EventBehavior + /// AddThe current predicate is evaluated. If the item passes, an Add is emitted. Otherwise dropped. + /// AddRangeEach item is evaluated. Matching items are emitted as AddRange. + /// ReplaceRe-evaluated. Same four-outcome logic as the static overload (Replace, Add, Remove, or dropped). + /// RemoveIf the item was downstream, a Remove is emitted. Otherwise dropped. + /// RefreshRe-evaluated. If inclusion status changed, an Add or Remove is emitted. If unchanged, Refresh forwarded or dropped. + /// ClearAll downstream items are cleared. + /// Predicate changedAll items are re-evaluated against the new predicate. The output is shaped by . + /// OnCompletedIndependent completion of does not terminate the filter. + /// + /// Worth noting: No items are included until emits its first function. + /// + /// or is . + /// + /// + public static IObservable> Filter(this IObservable> source, IObservable> predicate, ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); + + return new List.Internal.Filter.Dynamic(source, predicate, filterPolicy).Run(); + } + + /// + /// Filters items using a predicate that receives external state. When emits a new state value, + /// all items are re-evaluated against using the updated state. + /// + /// The type of the item. + /// The type of state value required by . + /// The source to filter. + /// An stream of state values to be passed to . + /// A static predicate receiving the current state and an item, returning to include or to exclude. The function itself does not change; only the state value passed to it changes. + /// The that controls re-filtering behavior when the state changes. + /// When (default), empty changesets are suppressed. Set to to publish empty changesets (useful for monitoring loading status). + /// A list changeset stream containing only items satisfying with the current state. + /// , , or is . + /// + /// + /// The predicate cannot be invoked until the first state value is received. Until then, all items are treated as excluded. + /// Each subsequent state emission triggers a full re-evaluation of all items according to . + /// + /// + /// EventBehavior + /// Add/AddRangeEvaluated using current state. Matching items emitted as Add/AddRange. + /// ReplaceRe-evaluated. Same four-outcome logic as the static filter (Replace, Add, Remove, or dropped). + /// Remove/RemoveRangeIf the item was downstream, a Remove is emitted. + /// RefreshRe-evaluated against current state. Inclusion status may change. + /// ClearAll downstream items are cleared. + /// State changedAll items are re-evaluated with the new state value. The output is shaped by . + /// + /// + /// + /// + public static IObservable> Filter( + this IObservable> source, + IObservable predicateState, + Func predicate, + ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff, + bool suppressEmptyChangeSets = true) + where T : notnull + => List.Internal.Filter.WithPredicateState.Create( + source: source, + predicateState: predicateState, + predicate: predicate, + filterPolicy: filterPolicy, + suppressEmptyChangeSets: suppressEmptyChangeSets); + + /// + /// Filters each item using a per-item of that dynamically controls inclusion. + /// When an item's observable emits the item enters the result; when it emits the item is removed. + /// + /// The type of items in the list. + /// The source to filter by property value. + /// A function that returns an observable of for each item, controlling its inclusion. + /// An optional throttle duration applied to each per-item observable to reduce re-evaluation frequency. + /// The used when throttling. Defaults to the system default scheduler. + /// A list changeset stream containing only items whose per-item observable most recently emitted . + /// or is . + /// + /// + /// Each item in the source gets its own subscription to the observable returned by . + /// The item's inclusion is determined by the most recent boolean value emitted by that observable. + /// + /// + /// Event (source)Behavior + /// Add/AddRangeSubscribes to the per-item observable. Item is included when it first emits . + /// ReplaceOld subscription disposed, new subscription created for the replacement item. + /// Remove/RemoveRange/ClearSubscription disposed. If the item was downstream, a Remove is emitted. + /// RefreshForwarded if the item is currently included. + /// + /// + /// Event (per-item observable)Behavior + /// Emits If not already included, an Add is emitted downstream. + /// Emits If currently included, a Remove is emitted downstream. + /// + /// + /// + /// + /// + /// + public static IObservable> FilterOnObservable(this IObservable> source, Func> objectFilterObservable, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new FilterOnObservable(source, objectFilterObservable, propertyChangedThrottle, scheduler).Run(); + } + + /// + /// Filters items based on a property value, automatically re-evaluating when the specified property changes on any item. + /// + /// The type of the object. Must implement . + /// The type of the property. + /// The source to filter by property value. + /// selecting the property to monitor for changes. + /// A predicate evaluated against the item to determine inclusion. + /// An optional throttle duration for property change notifications. + /// The used when throttling. + /// A list changeset stream of items satisfying the predicate, re-evaluated on property changes. + /// + /// Deprecated. Use followed by instead. + /// + /// + /// + [Obsolete("Use AutoRefresh(), followed by Filter() instead")] + public static IObservable> FilterOnProperty(this IObservable> source, Expression> propertySelector, Func predicate, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); + + return new FilterOnProperty(source, propertySelector, predicate, propertyChangedThrottle, scheduler).Run(); + } + + /// + /// Suppresses all changes from the stream. All other change reasons pass through. + /// + /// The type of the object. + /// The source to strip refresh events. + /// A list changeset stream with Refresh changes removed. + /// + /// + public static IObservable> SuppressRefresh(this IObservable> source) + where T : notnull => source.WhereReasonsAreNot(ListChangeReason.Refresh); + + /// + /// Filters the changeset stream to include only changes with the specified values. + /// Index information is stripped from the output because removing some changes invalidates the original index positions. + /// + /// The type of the item. + /// The source to filter by change reason. + /// The change reasons to include. Must specify at least one. + /// A list changeset stream containing only changes with the specified reasons. + /// is . + /// is empty. + /// + /// Filters individual changes within each changeset. If filtering removes all changes from a changeset, the empty changeset is suppressed via . + /// Worth noting: Filtering out Remove changes can cause downstream operators to accumulate items indefinitely (memory leak). Index information is stripped because removing some changes invalidates the original index positions. + /// + /// + /// + /// + public static IObservable> WhereReasonsAre(this IObservable> source, params ListChangeReason[] reasons) + where T : notnull + { + reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); + + if (reasons.Length == 0) + { + throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); + } + + var matches = new HashSet(reasons); + return source.Select( + changes => + { + var filtered = changes.Where(change => matches.Contains(change.Reason)).YieldWithoutIndex(); + return new ChangeSet(filtered); + }).NotEmpty(); + } + + /// + /// Filters the changeset stream to exclude changes with the specified values. + /// Index information is stripped from the output because removing some changes invalidates the original index positions. + /// The exception is when only is excluded, since removing Refresh does not affect index calculations. + /// + /// The type of the item. + /// The source to filter by excluding change reasons. + /// The change reasons to exclude. Must specify at least one. + /// A list changeset stream with the specified change reasons removed. + /// is . + /// is empty. + /// + /// + /// Empty changesets (after filtering) are automatically suppressed. When only is excluded, + /// indices are preserved, since removing Refresh does not affect index calculations. + /// + /// + /// + /// + /// + public static IObservable> WhereReasonsAreNot(this IObservable> source, params ListChangeReason[] reasons) + where T : notnull + { + reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); + + if (reasons.Length == 0) + { + throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); + } + + if (reasons.Length == 1 && reasons[0] == ListChangeReason.Refresh) + { + // If only refresh changes are removed, then there's no need to remove the indexes + return source.Select(changes => + { + var filtered = changes.Where(c => c.Reason != ListChangeReason.Refresh); + return new ChangeSet(filtered); + }).NotEmpty(); + } + + var matches = new HashSet(reasons); + return source.Select( + updates => + { + var filtered = updates.Where(u => !matches.Contains(u.Reason)).YieldWithoutIndex(); + return new ChangeSet(filtered); + }).NotEmpty(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Group.cs b/src/DynamicData/List/ObservableListEx.Group.cs new file mode 100644 index 00000000..0a94252b --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Group.cs @@ -0,0 +1,160 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for grouping operators. +/// +public static partial class ObservableListEx +{ + /// + /// Groups source items by the value returned by . Each group is an + /// containing an inner observable list of its members. + /// + /// The type of items in the list. + /// The type of the group key. + /// The source to group. + /// A function that returns the group key for each item. + /// An optional of that forces all items to be re-evaluated against when it fires. Useful for time-based groupings (e.g., "Last Hour", "Today"). + /// A list changeset stream of objects, each containing the items belonging to that group. + /// or is . + /// + /// + /// Groups are created lazily and removed when empty. Each group exposes an inner observable list that receives incremental updates. + /// + /// + /// EventBehavior + /// Add/AddRangeGroup key evaluated. Item added to its group. If the group is new, an Add of the group is emitted. + /// ReplaceGroup key re-evaluated. If the group changed, the item is removed from the old group and added to the new one. Empty old groups are removed. + /// Remove/RemoveRange/ClearItem removed from its group. Empty groups are removed from the result. + /// RefreshGroup key re-evaluated. If changed, the item moves between groups. + /// MovedNot handled by group logic. + /// Regrouper firesAll items re-evaluated. Items that changed group key are moved between groups. Empty groups removed, new groups added. + /// + /// + /// + /// + /// + public static IObservable>> GroupOn(this IObservable> source, Func groupSelector, IObservable? regrouper = null) + where TObject : notnull + where TGroup : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + groupSelector.ThrowArgumentNullExceptionIfNull(nameof(groupSelector)); + + return new GroupOn(source, groupSelector, regrouper).Run(); + } + + /// + /// Groups items by a property value, automatically re-grouping when the specified property changes on any item. + /// Each group contains an inner observable list. + /// + /// The type of the object. Must implement . + /// The type of the group key. + /// The source to group by property value. + /// selecting the property whose value determines the group key. + /// An optional throttle duration for property change notifications. + /// The used when throttling. + /// A list changeset stream of objects. + /// or is . + /// + /// + /// Convenience operator equivalent to .AutoRefresh(propertySelector).GroupOn(item => property). + /// Property changes trigger re-evaluation of the group key, potentially moving items between groups. + /// + /// + /// + /// + /// + public static IObservable>> GroupOnProperty(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TGroup : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + return new GroupOnProperty(source, propertySelector, propertyChangedThrottle, scheduler).Run(); + } + + /// + /// Groups items by a property value, automatically re-grouping when the specified property changes. + /// Each group emits immutable snapshots (not live observable lists). + /// + /// The type of the object. Must implement . + /// The type of the group key. + /// The source to group by property value with immutable snapshots. + /// selecting the property whose value determines the group key. + /// An optional throttle duration for property change notifications. + /// The used when throttling. + /// A list changeset stream of immutable group snapshots. + /// or is . + /// + /// + /// Combines + /// with . + /// Unlike , + /// this produces immutable snapshots per group rather than live inner observable lists. + /// + /// + /// + /// + public static IObservable>> GroupOnPropertyWithImmutableState(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) + where TObject : INotifyPropertyChanged + where TGroup : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); + + return new GroupOnPropertyWithImmutableState(source, propertySelector, propertyChangedThrottle, scheduler).Run(); + } + + /// + /// Groups source items by the value returned by . Each update produces immutable grouping snapshots + /// rather than live inner observable lists. + /// + /// The type of items in the list. + /// The type of the group key. + /// The source to group with immutable snapshots. + /// A function that returns the group key for each item. + /// An optional of that forces all items to be re-evaluated when it fires. + /// A list changeset stream of immutable snapshots. + /// or is . + /// + /// + /// Works like + /// but each affected group emits a new immutable snapshot on every change rather than updating a live inner list. + /// This is useful when consumers need thread-safe, point-in-time snapshots of each group. + /// + /// + /// + /// + public static IObservable>> GroupWithImmutableState(this IObservable> source, Func groupSelectorKey, IObservable? regrouper = null) + where TObject : notnull + where TGroupKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); + + return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Lifecycle.cs b/src/DynamicData/List/ObservableListEx.Lifecycle.cs new file mode 100644 index 00000000..435c62fa --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Lifecycle.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for subscription lifecycle, disposal, and population. +/// +public static partial class ObservableListEx +{ + /// + /// Disposes items that implement when they are removed, replaced, or cleared from the stream. + /// All remaining tracked items are disposed when the stream finalizes (OnCompleted, OnError, or subscription disposal). + /// + /// The type of the object. + /// The source to track for disposal on removal. + /// A continuation of the source changeset stream with disposal side effects applied. + /// is . + /// + /// + /// Items are cast to and disposed after the changeset has been forwarded downstream. + /// Items that do not implement are silently ignored. + /// + /// + /// EventBehavior + /// Add/AddRangeItems are tracked for future disposal. Changeset forwarded. + /// ReplaceThe previous (replaced) item is disposed after the changeset is forwarded. The new item is tracked. + /// Remove/RemoveRangeRemoved items are disposed after the changeset is forwarded. + /// ClearAll tracked items are disposed after the changeset is forwarded. + /// Moved/RefreshForwarded. No disposal occurs. + /// OnError/OnCompleted/DisposalAll remaining tracked items are disposed during finalization. + /// + /// Worth noting: Disposal happens after the changeset is delivered downstream, so subscribers see the change before items are disposed. + /// + /// + /// + /// + public static IObservable> DisposeMany(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new DisposeMany(source).Run(); + } + + /// + /// Subscribes to the source changeset stream and pipes all changes into the . + /// + /// The type of the object. + /// The source to pipe into a target list. + /// The destination to receive all changes. + /// An representing the subscription. Dispose to stop piping changes. + /// or is . + /// + /// Each changeset is applied to the destination using Clone() inside an Edit() call, producing a single batch update per changeset. + /// + /// + /// + /// + public static IDisposable PopulateInto(this IObservable> source, ISourceList destination) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); + + return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); + } + + /// + /// Creates an subscription for each item via when it is added. + /// The subscription is disposed when the item is removed or replaced. All subscriptions are disposed when the stream terminates. + /// The changeset is forwarded downstream unmodified. + /// + /// The type of the object. + /// The source to create a subscription for each item in. + /// A function that creates an for each item. + /// A continuation of the source changeset stream with per-item subscriptions managed as a side effect. + /// or is . + /// + /// + /// EventBehavior + /// Add/AddRangeSubscription created for each item via the factory. Changeset forwarded. + /// ReplaceOld item's subscription disposed, new subscription created. Changeset forwarded. + /// Remove/RemoveRange/ClearSubscriptions for removed items are disposed. Changeset forwarded. + /// Moved/RefreshForwarded. No subscription changes. + /// OnError/OnCompleted/DisposalAll active subscriptions are disposed. + /// + /// + /// + /// + /// + /// + public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); + + return new SubscribeMany(source, subscriptionFactory).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Merge.cs b/src/DynamicData/List/ObservableListEx.Merge.cs new file mode 100644 index 00000000..d31b570d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Merge.cs @@ -0,0 +1,415 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for MergeMany, MergeChangeSets, MergeManyChangeSets, and Switch. +/// +public static partial class ObservableListEx +{ + /// + /// Subscribes to a per-item observable for each item in the source and merges all emissions into a single stream. + /// This is NOT a changeset operator: it returns a flat observable of values. + /// + /// The type of items in the source list. + /// The type of values emitted by per-item observables. + /// The source whose items each produce an observable. + /// A function that returns an observable for each source item. + /// An observable that emits values from all per-item observables, merged together. + /// or is . + /// + /// + /// Event (source)Subscription behavior + /// Add/AddRangeSubscribes to the per-item observable. Emissions are merged into the output. + /// ReplaceOld subscription disposed, new subscription created for the replacement item. + /// Remove/RemoveRange/ClearSubscription disposed. + /// Refresh/MovedNo effect on subscriptions. + /// OnCompleted (source)Completes only after the source and all active inner observables have completed. + /// + /// + /// + /// + /// + /// + public static IObservable MergeMany(this IObservable> source, Func> observableSelector) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeMany(source, observableSelector).Run(); + } + + /// + /// + /// Merges multiple list changeset streams from an observable-of-observables into a single unified changeset stream. + /// Unlike , list merging performs no key-based deduplication. + /// + /// The source of nested changeset observables. + /// An optional used by the merge tracker to compare items. + public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer? equalityComparer = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new MergeChangeSets(source, equalityComparer).Run(); + } + + /// + /// + /// Merges two list changeset streams into a single unified stream. + /// + /// The first to merge. + /// The second to merge with. + /// An optional used to compare items. + /// An optional for scheduling enumeration. + /// When (default), the result completes when all sources complete. + public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + other.ThrowArgumentNullExceptionIfNull(nameof(other)); + + return new[] { source, other }.MergeChangeSets(equalityComparer, scheduler, completable); + } + + /// + /// + /// Merges the source list changeset stream with additional changeset streams into a single unified stream. + /// + /// The primary source to merge. + /// The additional of list changeset streams to merge with. + /// An optional used to compare items. + /// An optional for scheduling enumeration. + /// When (default), the result completes when all sources complete. + public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, scheduler, completable); + } + + /// + /// Merges a collection of list changeset streams into a single unified changeset stream. + /// This is the canonical list MergeChangeSets overload: other overloads accepting , , or pair/params variants ultimately produce equivalent behavior. + /// + /// The type of items in the list. + /// The collection of list changeset streams to merge. + /// An optional used by the merge tracker to compare items. Defaults to when . + /// An optional for scheduling enumeration. + /// When (default), the result completes when all sources complete. + /// A single list changeset stream containing all changes from all sources. + /// is . + /// + /// + /// All changes from inner streams are forwarded to the output. There is no key-based deduplication (unlike ): if the same item appears in multiple inner streams, it will appear multiple times in the merged output. + /// + /// + /// EventBehavior + /// Add/AddRangeForwarded to the merged output. + /// ReplaceThe old value is replaced by the new value in the merged output. If the old value is not found (by ), the new value is added instead. + /// Remove/RemoveRange/ClearForwarded to the merged output. + /// RefreshForwarded to the merged output. + /// MovedIgnored. + /// + /// + /// + /// + /// + /// + public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new MergeChangeSets(source, equalityComparer, completable, scheduler).Run(); + } + + /// + /// + /// Merges list changeset streams from an into a single stream. Sources can be added or removed dynamically. + /// + public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().MergeChangeSets(equalityComparer); + } + + /// + /// + /// Merges list changeset streams from a list-of-list-changeset-observables into a single stream. + /// Each inner list changeset observable in the source list is merged, and parent item removal triggers child cleanup. + /// + public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeManyChangeSets(static src => src, equalityComparer); + } + + /// + /// Merges cache changeset streams from an into a single cache changeset stream. + /// Uses to resolve conflicts when the same key appears in multiple child streams. + /// + /// The type of items in the list. + /// The type of the object key. + /// The of cache changeset observables. + /// to resolve which value wins when the same key appears in multiple sources. + /// A single cache changeset stream with key-based deduplication. + /// is . + /// + /// Sources can be added or removed dynamically from the observable list. Parent item removal triggers cleanup of all child items from that source. + /// + /// EventBehavior + /// Add (child)If the destination key is new, an Add is emitted. If another source already contributed a child with the same key, resolves the conflict (lowest-ordered value wins). The losing value is tracked internally but not emitted. + /// Update (child)If this source currently owns the destination key downstream, an Update is emitted. Otherwise re-evaluates all sources; a different source's value may win, producing an Update to that value instead. + /// Remove (child)If this source's value was the one published downstream for that destination key, the operator scans other sources for the same key. If found, an Update is emitted with the replacement (per ). Otherwise a Remove is emitted. + /// Refresh (child)If the child item is the one currently published downstream, the Refresh is forwarded. Otherwise re-evaluates all sources; if a different value now wins, an Update is emitted instead. + /// Source list AddSubscribes to the new child changeset stream and merges its keys into the output. + /// Source list RemoveDisposes that source's subscription. All keys it contributed are removed. For keys also contributed by other sources, the next-best value (per ) is promoted as an Update, not an Add. + /// + /// + /// + /// + public static IObservable> MergeChangeSets(this IObservableList>> source, IComparer comparer) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().MergeChangeSets(comparer); + } + + /// + /// + /// Merges cache changeset streams from an into a single cache changeset stream, with optional equality and ordering comparers. + /// + /// The of cache changeset observables. + /// An optional to determine if two elements are the same. + /// An optional to resolve conflicts when the same key appears in multiple sources. + public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Connect().MergeChangeSets(equalityComparer, comparer); + } + + /// + /// + /// Merges cache changeset streams from a list changeset of cache changeset observables, using a comparer for conflict resolution. + /// + /// The source whose items are cache changeset observables. + /// to resolve which value wins when the same key appears in multiple sources. + public static IObservable> MergeChangeSets(this IObservable>>> source, IComparer comparer) + where TObject : notnull + where TKey : notnull + { + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.MergeChangeSets(comparer); + } + + /// + /// + /// Merges cache changeset streams from a list changeset of cache changeset observables, with optional equality and ordering comparers. + /// + /// The source whose items are cache changeset observables. + /// An optional to determine if two elements are the same. + /// An optional to resolve conflicts when the same key appears in multiple sources. + public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeManyChangeSets(static src => src, equalityComparer, comparer); + } + + /// + /// Transforms each source item into a child list changeset stream using , + /// then merges all child streams into a single flat list changeset stream. Parent item removal cleans up all associated children. + /// + /// The type of items in the source list. + /// The type of items in the child changeset streams. + /// The source whose items each produce a child changeset stream. + /// A function that returns a child list changeset stream for each source item. + /// An optional used to compare child items. + /// A single list changeset stream containing all items from all child streams. + /// or is . + /// + /// + /// Internally subscribes to each child stream when a source item is added and disposes the subscription when it is removed. + /// All child items from a removed parent are removed from the merged output. + /// + /// + /// Event (source)Behavior + /// Add/AddRangeSubscribes to the child stream. Child emissions are merged into the output. + /// ReplaceOld child subscription disposed (and its items removed from output). New child subscription created. + /// Remove/RemoveRange/ClearChild subscription disposed. All child items from that parent are removed. + /// + /// + /// + /// + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) + where TObject : notnull + where TDestination : notnull + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (observableSelector == null) + { + throw new ArgumentNullException(nameof(observableSelector)); + } + + return new MergeManyListChangeSets(source, observableSelector, equalityComparer).Run(); + } + + /// + /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. + /// Uses to resolve key conflicts when the same key appears in multiple child streams. + /// + /// The type of items in the source list. + /// The type of items in the child cache changeset streams. + /// The type of the key in the child cache changesets. + /// The source whose items each produce a child changeset stream. + /// A function that returns a child cache changeset stream for each source item. + /// to resolve which value wins when the same key appears from multiple children. + /// A single cache changeset stream with key-based deduplication. + /// , , or is . + /// + /// + /// Delegates to with a equality comparer. + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) + where TObject : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return source.MergeManyChangeSets(observableSelector, equalityComparer: null, comparer: comparer); + } + + /// + /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. + /// This is the primary list-to-cache MergeManyChangeSets overload. + /// + /// The type of items in the source list. + /// The type of items in the child cache changeset streams. + /// The type of the key in the child cache changesets. + /// The source whose items each produce a child changeset stream. + /// A function that returns a child cache changeset stream for each source item. + /// An optional to determine if two elements are the same. + /// An optional to resolve conflicts when the same key appears from multiple children. + /// A single cache changeset stream with key-based deduplication. + /// or is . + /// + /// + /// Each source item produces a keyed child stream via . All child items are tracked by key. + /// When a parent item is removed, all its child items are removed from the merged output. + /// When the same key appears from multiple children, determines which value wins. + /// + /// + /// Event (source)Behavior + /// Add/AddRangeSubscribes to the child cache stream. Child key/value pairs are merged into the output cache. + /// ReplaceOld child subscription disposed (and its keys removed from output). New child subscription created. + /// Remove/RemoveRange/ClearChild subscription disposed. All keys originating from that child are removed from the output. + /// Moved/RefreshIgnored; this operator emits a cache changeset and source ordering/refresh does not affect key membership. + /// + /// + /// Error and completion: + /// + /// + /// EventBehavior + /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. + /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. + /// + /// + /// + /// + public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) + where TObject : notnull + where TDestination : notnull + where TDestinationKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); + } + + /// + /// Subscribes to the latest inner , switching to each new source and clearing the result when switching. + /// This is the changeset-aware equivalent of Rx's , which cannot be applied directly to changeset streams. + /// + /// The type of the object. + /// An observable that emits instances. Each emission triggers a switch to the new list. + /// A list changeset stream reflecting the most recently received inner list. + /// is . + /// + /// Convenience overload that calls Connect() on each inner list, then delegates to . + /// + /// + public static IObservable> Switch(this IObservable> sources) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return sources.Select(cache => cache.Connect()).Switch(); + } + + /// + /// Subscribes to the latest inner changeset stream, switching to each new source and clearing the destination when switching. + /// Previous subscriptions are disposed and the result set is emptied before subscribing to the new inner stream. + /// + /// The type of the object. + /// An of changeset streams. The operator subscribes to the latest inner stream. + /// A list changeset stream reflecting the most recently received inner changeset stream. + /// is . + /// + /// + /// On each new inner stream, the operator clears the destination, disposes the previous subscription, and subscribes to the new stream. + /// This is the changeset-aware equivalent of Rx's Switch(). + /// + /// + /// + public static IObservable> Switch(this IObservable>> sources) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new Switch(sources).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Notifications.cs b/src/DynamicData/List/ObservableListEx.Notifications.cs new file mode 100644 index 00000000..b39ac70d --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Notifications.cs @@ -0,0 +1,119 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for per-item change-reason notifications. +/// +public static partial class ObservableListEx +{ + /// + /// Invokes for every item added to the source list stream. + /// Triggers on , , and the new item of . + /// + /// The type of items in the list. + /// The source to observe item additions in. + /// The action to invoke for each added item. + /// A continuation of the source changeset stream, with the side effect applied before forwarding. + /// or is . + /// + /// The action fires before the changeset is forwarded downstream. + /// + /// EventBehavior + /// AddCallback invoked with the added item. Changeset forwarded. + /// AddRangeCallback invoked for each item in the range. Changeset forwarded. + /// ReplaceCallback invoked for the new (replacement) item. Changeset forwarded. + /// Remove/RemoveRange/ClearNo callback. Changeset forwarded. + /// Moved/RefreshNo callback. Changeset forwarded. + /// OnErrorIf the callback throws, the exception propagates as OnError. + /// + /// + /// + /// + /// + /// + public static IObservable> OnItemAdded( + this IObservable> source, + Action addAction) + where T : notnull + => List.Internal.OnItemAdded.Create( + source: source, + addAction: addAction); + + /// + /// Invokes for every item with a change in the source stream. + /// + /// The type of items in the list. + /// The source to observe item refresh events in. + /// The action to invoke for each refreshed item. + /// A continuation of the source changeset stream, with the side effect applied before forwarding. + /// or is . + /// + /// + /// + /// + public static IObservable> OnItemRefreshed( + this IObservable> source, + Action refreshAction) + where T : notnull + => List.Internal.OnItemRefreshed.Create( + source: source, + refreshAction: refreshAction); + + /// + /// Invokes for every item removed from the source list stream. + /// Triggers on , , , and the old item of . + /// + /// The type of items in the list. + /// The source to observe item removals in. + /// The action to invoke for each removed item. + /// When (default), is also invoked for all remaining tracked items upon stream disposal, completion, or error. + /// A continuation of the source changeset stream, with the side effect applied before forwarding. + /// or is . + /// + /// + /// When is , the operator tracks all items that have been added but not yet removed, + /// and fires for each of them during finalization. This is useful for resource cleanup patterns. + /// + /// + /// EventBehavior + /// Add/AddRangeTracked internally (when is ). No callback invoked. Changeset forwarded. + /// ReplaceCallback invoked for the previous (replaced) item. New item tracked. Changeset forwarded. + /// RemoveCallback invoked for the removed item. Changeset forwarded. + /// RemoveRange/ClearCallback invoked for each removed item. Changeset forwarded. + /// Moved/RefreshNo callback. Changeset forwarded. + /// OnErrorIf is , callback is invoked for all tracked items before the error propagates. + /// OnCompletedIf is , callback is invoked for all tracked items before completion propagates. + /// + /// Worth noting: When is (the default), disposing the subscription also invokes the callback for every item still in the list, not just items that were explicitly removed during the subscription. Exceptions in are not caught. + /// + /// + /// + /// + /// + public static IObservable> OnItemRemoved( + this IObservable> source, + Action removeAction, + bool invokeOnUnsubscribe = true) + where T : notnull + => List.Internal.OnItemRemoved.Create( + source: source, + removeAction: removeAction, + invokeOnUnsubscribe: invokeOnUnsubscribe); +} diff --git a/src/DynamicData/List/ObservableListEx.Pagination.cs b/src/DynamicData/List/ObservableListEx.Pagination.cs new file mode 100644 index 00000000..e1106cbe --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Pagination.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for Page and Virtualise. +/// +public static partial class ObservableListEx +{ + /// + /// Applies page-based windowing to the source list. Only items within the current page (determined by page number and page size from ) are included downstream. + /// + /// The type of the item. + /// The source to page. + /// An observable of controlling which page to display (page number and page size). + /// An stream containing only items within the current page window. + /// or is . + /// + /// + /// Maintains the full source list internally and calculates the page window on each change or page request. + /// Items entering the page window produce Add; items leaving produce Remove. A new page request triggers + /// a full recalculation of the page contents. + /// + /// Worth noting: Duplicate items are removed from the result via Distinct() using the default equality comparer for , regardless of source order. The source should ideally be sorted before paging, since list order determines which items fall within each page window. + /// + /// + /// + /// + public static IObservable> Page(this IObservable> source, IObservable requests) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); + + return new Pager(source, requests).Run(); + } + + /// + /// Applies a sliding window to the source list using start index and size from . + /// Only items within the window are included downstream. + /// + /// The type of the item. + /// The source to virtualize. + /// An observable of specifying the start index and size of the window. + /// An stream containing only items within the current virtual window. + /// or is . + /// + /// + /// Like but uses absolute start index and size instead of page number and page size. + /// Internally maintains the full source list and recalculates the window on each change or request. + /// + /// + /// + /// + public static IObservable> Virtualise(this IObservable> source, IObservable requests) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); + + return new Virtualiser(source, requests).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.PropertyChanged.cs b/src/DynamicData/List/ObservableListEx.PropertyChanged.cs new file mode 100644 index 00000000..3b44d733 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.PropertyChanged.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for property-change observation. +/// +public static partial class ObservableListEx +{ + /// + /// Watches all items in the source list and emits the item when any of its properties change. + /// Requires to implement . + /// This is NOT a changeset operator: it returns a flat . + /// + /// The type of the object. Must implement . + /// The source to observe property changes on items in. + /// An optional list of property names to monitor. If empty, all property changes are observed. + /// An observable emitting the item whenever any monitored property changes. + /// is . + /// + /// Implemented via . Subscriptions are managed per item: created on add, disposed on remove. + /// + /// + /// + /// + /// + public static IObservable WhenAnyPropertyChanged(this IObservable> source, params string[] propertiesToMonitor) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.MergeMany(t => t.WhenAnyPropertyChanged(propertiesToMonitor)); + } + + /// + /// Watches a specific property on all items in the source list and emits a (item + value pair) when it changes. + /// Requires to implement . + /// This is NOT a changeset operator: it returns a flat . + /// + /// The type of item. Must implement . + /// The type of the property value. + /// The source to observe a specific property on items in. + /// An expression selecting the property to observe. + /// When (default), the current value is emitted immediately upon subscribing to each item. + /// An observable emitting whenever the property changes on any tracked item. + /// or is . + /// + /// Implemented via . + /// + /// + /// + /// + public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + var factory = propertyAccessor.GetFactory(); + return source.MergeMany(t => factory(t, notifyOnInitialValue)); + } + + /// + /// Watches a specific property on all items and emits just the property value (without the sender) when it changes. + /// Requires to implement . + /// This is NOT a changeset operator: it returns a flat . + /// + /// The type of item. Must implement . + /// The type of the property value. + /// The source to observe a specific property value on items in. + /// An expression selecting the property to observe. + /// When (default), the current value is emitted immediately upon subscribing to each item. + /// An observable emitting the property value whenever it changes on any tracked item. + /// or is . + /// + /// + /// + public static IObservable WhenValueChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) + where TObject : INotifyPropertyChanged + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); + + var factory = propertyAccessor.GetFactory(); + return source.MergeMany(t => factory(t, notifyOnInitialValue).Select(pv => pv.Value)); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Query.cs b/src/DynamicData/List/ObservableListEx.Query.cs new file mode 100644 index 00000000..24fff8d8 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Query.cs @@ -0,0 +1,279 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for querying and snapshot collection projection. +/// +public static partial class ObservableListEx +{ + /// + /// Emits a projected value from the current list snapshot after every changeset. + /// The receives an representing the current state. + /// + /// The type of items in the list. + /// The type of the projected result. + /// The source to project on each change. + /// A function projecting the current list snapshot to a result value. + /// An observable emitting the projected value after each changeset. + /// or is . + /// + /// Delegates to and applies via Select. + /// + /// + /// + /// + public static IObservable QueryWhenChanged(this IObservable> source, Func, TDestination> resultSelector) + where TObject : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); + + return source.QueryWhenChanged().Select(resultSelector); + } + + /// + /// Emits an snapshot of the current list state after every changeset. + /// Maintains an internal list updated by cloning each changeset. + /// + /// The type of items in the list. + /// The source to project on each change. + /// An observable emitting the full list snapshot as after each change. + /// is . + /// + /// This is a non-changeset operator. It emits the entire collection state on each change, not incremental diffs. + /// Worth noting: A new snapshot is emitted on every changeset, which can be chatty. The collection is rebuilt by cloning each changeset into an internal list. For sorted output, use . + /// + /// + /// + /// + public static IObservable> QueryWhenChanged(this IObservable> source) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return new QueryWhenChanged(source).Run(); + } + + /// + /// Emits the full collection as an after every changeset. Equivalent to QueryWhenChanged(items => items). + /// + /// The type of items in the list. + /// The source to materialize into a collection on each change. + /// An observable emitting the full collection snapshot after each change. + /// + /// + /// + public static IObservable> ToCollection(this IObservable> source) + where TObject : notnull => source.QueryWhenChanged(items => items); + + /// + /// Bridges an into the DynamicData world by converting each emitted item into a list changeset. + /// Each emission becomes an Add operation in the resulting changeset stream. + /// + /// The type of the object. + /// The source to convert into a changeset stream. + /// An optional for time-based operations (expiry, size limiting). + /// A list changeset stream where each source emission is an Add. + /// is . + /// + /// + /// This is the primary bridge from standard Rx into DynamicData's list changeset model. Each item emitted by + /// is added to an internal list and an Add changeset is emitted. The list grows unboundedly unless size or time limits + /// are specified via other overloads. + /// + /// Worth noting: Source completion and errors are propagated. The internal list is disposed on unsubscribe. + /// + /// + /// + public static IObservable> ToObservableChangeSet( + this IObservable source, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an into a list changeset stream with per-item time-based expiry. + /// Expired items are automatically removed. + /// + /// The source to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// An optional for expiry timers. + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func expireAfter, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an into a list changeset stream with FIFO size limiting. + /// When the list exceeds , the oldest items are removed. + /// + /// The source to convert into a changeset stream. + /// The maximum list size. Supply -1 to disable size limiting. + /// An optional for scheduling removals. + public static IObservable> ToObservableChangeSet( + this IObservable source, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// + /// Bridges an into a list changeset stream with both time-based expiry and FIFO size limiting. + /// + /// The source to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// The maximum list size. Supply -1 to disable size limiting. + /// An optional for expiry timers and size-limit checks. + public static IObservable> ToObservableChangeSet( + this IObservable source, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream. + /// Each emitted batch becomes an AddRange. + /// + /// The source of to convert into a changeset stream. + /// An optional for time-based operations. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream with FIFO size limiting. + /// + /// The source of to convert into a changeset stream. + /// The maximum list size. Oldest items are removed when the limit is exceeded. + /// An optional for scheduling removals. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: null, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream with time-based expiry. + /// + /// The source of to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// An optional for expiry timers. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func expireAfter, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: -1, + scheduler: scheduler); + + /// + /// + /// Bridges an of batches into a list changeset stream with both time-based expiry and FIFO size limiting. + /// + /// The source of to convert into a changeset stream. + /// A function returning the time-to-live for each item. Return for non-expiring items. + /// The maximum list size. Oldest items removed when exceeded. + /// An optional for expiry timers and size-limit checks. + public static IObservable> ToObservableChangeSet( + this IObservable> source, + Func? expireAfter, + int limitSizeTo, + IScheduler? scheduler = null) + where T : notnull + => List.Internal.ToObservableChangeSet.Create( + source: source, + expireAfter: expireAfter, + limitSizeTo: limitSizeTo, + scheduler: scheduler); + + /// + /// Emits a sorted after every changeset, sorted by the value returned by . + /// + /// The type of items in the list. + /// The type of the sort key. + /// The source to materialize into a sorted collection on each change. + /// A function extracting the sort key from each item. + /// The sort direction. Defaults to ascending. + /// An observable emitting a sorted collection snapshot after each change. + /// + /// + /// + /// + public static IObservable> ToSortedCollection(this IObservable> source, Func sort, SortDirection sortOrder = SortDirection.Ascending) + where TObject : notnull => source.QueryWhenChanged(query => sortOrder == SortDirection.Ascending ? new ReadOnlyCollectionLight(query.OrderBy(sort)) : new ReadOnlyCollectionLight(query.OrderByDescending(sort))); + + /// + /// Emits a sorted after every changeset, sorted using the specified . + /// + /// The type of items in the list. + /// The source to materialize into a sorted collection on each change. + /// The used for sorting. + /// An observable emitting a sorted collection snapshot after each change. + /// + /// + public static IObservable> ToSortedCollection(this IObservable> source, IComparer comparer) + where TObject : notnull => source.QueryWhenChanged( + query => + { + var items = query.AsList(); + items.Sort(comparer); + return new ReadOnlyCollectionLight(items); + }); +} diff --git a/src/DynamicData/List/ObservableListEx.Sort.cs b/src/DynamicData/List/ObservableListEx.Sort.cs new file mode 100644 index 00000000..c6824721 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Sort.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for Sort and Reverse. +/// +public static partial class ObservableListEx +{ + /// + /// Reverses the order of items in the changeset stream by transforming all indices: new_index = length - old_index - 1. + /// + /// The type of the item. + /// The source to reverse. + /// A list changeset stream with all index positions reversed. + /// is . + /// + /// This is a pure index transformation. The items themselves are unchanged; only their positional indices are inverted. + /// + /// + public static IObservable> Reverse(this IObservable> source) + where T : notnull + { + var reverser = new Reverser(); + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + return source.Select(changes => new ChangeSet(reverser.Reverse(changes))); + } + + /// + /// Sorts the list using the specified comparer, maintaining a sorted output that incrementally updates as items change. + /// + /// The type of the item. + /// The source to sort. + /// The used for sorting. + /// The for improved performance when sorted values are immutable. + /// An optional of that forces a full re-sort when it fires. Required when sorted property values are mutable. + /// An optional of that replaces the comparer, triggering a full re-sort. + /// When the number of changes exceeds this threshold, a full reset is performed instead of incremental updates. Default is 50. + /// A list changeset stream with items in sorted order. + /// or is . + /// + /// + /// Maintains an internal sorted list. Each incoming change is applied incrementally: adds are inserted at the correct sorted position, + /// removes are removed by index, and refreshes re-evaluate position (emitting Moved if changed). + /// + /// + /// EventBehavior + /// Add/AddRangeInserted at the correct sorted position. May trigger a full reset if the count exceeds . + /// ReplaceOld item removed, new item inserted at sorted position. + /// Remove/RemoveRange/ClearRemoved from sorted list. + /// RefreshSort position re-evaluated. If position changed, a Moved is emitted. + /// Comparer changedFull re-sort of all items. + /// Re-sort signalFull re-sort using the current comparer. + /// + /// Worth noting: is faster but requires that the values being sorted on never mutate. If they do, use the signal or . + /// + /// + /// + /// + /// + public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptions options = SortOptions.None, IObservable? resort = null, IObservable>? comparerChanged = null, int resetThreshold = 50) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); + + return new Sort(source, comparer, options, resort, comparerChanged, resetThreshold).Run(); + } + + /// + /// + /// Sorts the list using an observable comparer. The initial comparer is taken from the first emission; subsequent emissions trigger a full re-sort. + /// + /// + /// Until emits its first comparer, items are sorted using . Downstream still receives changesets immediately; the initial ordering is whatever produces, then a full re-sort happens once the first comparer arrives. + /// + /// The source to sort. + /// An of that emits comparers. The first emission provides the initial sort order; subsequent emissions trigger re-sorts. + /// for controlling sort behavior. + /// An optional of to force a re-sort with the current comparer. + /// The threshold for triggering a full reset instead of incremental updates. + public static IObservable> Sort(this IObservable> source, IObservable> comparerChanged, SortOptions options = SortOptions.None, IObservable? resort = null, int resetThreshold = 50) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparerChanged.ThrowArgumentNullExceptionIfNull(nameof(comparerChanged)); + + return new Sort(source, null, options, resort, comparerChanged, resetThreshold).Run(); + } +} diff --git a/src/DynamicData/List/ObservableListEx.Transform.cs b/src/DynamicData/List/ObservableListEx.Transform.cs new file mode 100644 index 00000000..db682e41 --- /dev/null +++ b/src/DynamicData/List/ObservableListEx.Transform.cs @@ -0,0 +1,265 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Binding; +using DynamicData.Cache.Internal; +using DynamicData.List.Internal; +using DynamicData.List.Linq; + +// ReSharper disable once CheckNamespace +namespace DynamicData; + +/// +/// ObservableList extensions for Transform, TransformAsync, and TransformMany. +/// +public static partial class ObservableListEx +{ + /// + /// Projects each item to a new form using a synchronous transform function. + /// + /// The type of the source items. + /// The type of the destination items. + /// The source to transform. + /// The transform function applied to each item. + /// When , Refresh events re-invoke the factory and emit an update. When (the default), Refresh is forwarded without re-transforming. + /// A list changeset stream of transformed items. + /// + /// + /// Maintains an internal list of transformed items. Each source changeset is + /// processed and a corresponding output changeset is produced with the transformed items. + /// + /// + /// EventBehavior + /// AddThe factory is called and an Add is emitted at the same index. + /// AddRangeThe factory is called for each item. An AddRange is emitted at the same start index. + /// ReplaceThe factory is called for the new item. A Replace is emitted at the same index. The previous transformed value is available to overloads that accept . + /// RemoveA Remove is emitted (no factory call). + /// RemoveRangeA RemoveRange is emitted. + /// MovedA Moved is emitted with updated indices (no factory call). Throws if the source change has no index information. + /// RefreshIf is (default), the Refresh is forwarded without re-transforming. If , the factory is re-invoked and the result replaces the current value. + /// ClearA Clear is emitted and the internal list is emptied. + /// OnErrorIf the factory throws, the exception propagates as OnError. + /// + /// Worth noting: By default, Refresh does NOT re-transform the item (it just forwards the signal). Set to if you need the factory re-invoked on Refresh. Add operations with out-of-bounds indices silently append to the end. + /// + /// or is . + /// + /// + /// + /// + public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((t, _, _) => transformFactory(t), transformOnRefresh); + } + + /// + /// + /// Projects each item using a transform function that also receives the item's index. + /// + public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((t, _, idx) => transformFactory(t, idx), transformOnRefresh); + } + + /// + /// + /// Projects each item using a transform function that also receives the previously transformed value (if any). + /// Type arguments must be specified explicitly as type inference fails for this overload. + /// + public static IObservable> Transform(this IObservable> source, Func, TDestination> transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.Transform((t, previous, _) => transformFactory(t, previous), transformOnRefresh); + } + + /// + /// + /// Projects each item using a transform function that receives the source item, the previously transformed value, and the index. + /// Type arguments must be specified explicitly as type inference fails for this overload. + /// + public static IObservable> Transform(this IObservable> source, Func, int, TDestination> transformFactory, bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new Transformer(source, transformFactory, transformOnRefresh).Run(); + } + + /// + /// Projects each item to a new form using an async transform function. Behaves like but the factory returns a . + /// + /// The type of the source items. + /// The type of the destination items. + /// The source to transform asynchronously. + /// An async function that transforms each source item. + /// When , Refresh events re-invoke the factory. + /// A list changeset stream of asynchronously transformed items. + /// or is . + /// + /// Change handling is identical to the synchronous except the factory is awaited. Operations are serialized per changeset via a semaphore. + /// + /// EventBehavior + /// Add/AddRangeThe async factory is awaited for each item. An Add/AddRange is emitted with the transformed results. + /// ReplaceThe async factory is awaited for the new item. A Replace is emitted. + /// Remove/RemoveRangeEmitted without invoking the factory. + /// MovedEmitted with updated indices (no factory call). + /// RefreshIf is (default), forwarded without re-transforming. If , the factory is re-awaited. + /// ClearEmitted and internal list cleared. + /// OnErrorIf the async factory throws, the exception propagates as OnError. + /// OnCompletedForwarded after the last changeset is processed. + /// + /// Worth noting: All async transforms within a single changeset are serialized (not parallel). Each changeset is fully processed before the next begins. By default, Refresh does NOT re-transform. + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((t, _, _) => transformFactory(t), transformOnRefresh); + } + + /// + /// + /// Async transform overload receiving the source item and its index. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((t, _, i) => transformFactory(t, i), transformOnRefresh); + } + + /// + /// + /// Async transform overload receiving the source item and the previously transformed value. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func, Task> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return source.TransformAsync((t, d, _) => transformFactory(t, d), transformOnRefresh); + } + + /// + /// + /// Async transform overload receiving the source item, previously transformed value, and index. This is the terminal overload that all other TransformAsync overloads delegate to. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] + public static IObservable> TransformAsync( + this IObservable> source, + Func, int, Task> transformFactory, + bool transformOnRefresh = false) + where TSource : notnull + where TDestination : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); + + return new TransformAsync(source, transformFactory, transformOnRefresh).Run(); + } + + /// + /// Flattens each source item into multiple destination items using . Each source item produces zero or more children, + /// all of which are merged into a single flat list changeset stream. + /// + /// The type of the destination items. + /// The type of the source items. + /// The source to expand each item into multiple children. + /// A function that returns the child items for each source item. + /// An optional used during Replace to determine which child items changed between old and new parent values. + /// A list changeset stream of all child items from all source items. + /// or is . + /// + /// + /// EventBehavior + /// Add/AddRangeChildren expanded and added to the output. + /// ReplaceOld children diffed against new children (using ). Removed, added, or kept as appropriate. + /// Remove/RemoveRange/ClearAll children of the removed parents are removed from the output. + /// RefreshChildren re-expanded and diffed. + /// + /// + /// + /// + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); + + return new TransformMany(source, manySelector, equalityComparer).Run(); + } + + /// + /// + /// Flattens each source item into children from an . The collection is observed for subsequent changes. + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); + + /// + /// + /// Flattens each source item into children from a . The collection is observed for subsequent changes. + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); + + /// + /// + /// Flattens each source item into children from an . The inner list is observed for subsequent changes. + /// + public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) + where TDestination : notnull + where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); +} diff --git a/src/DynamicData/List/ObservableListEx.cs b/src/DynamicData/List/ObservableListEx.cs deleted file mode 100644 index 37f3a396..00000000 --- a/src/DynamicData/List/ObservableListEx.cs +++ /dev/null @@ -1,2929 +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.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using DynamicData.Binding; -using DynamicData.Cache.Internal; -using DynamicData.List.Internal; -using DynamicData.List.Linq; - -// ReSharper disable once CheckNamespace -namespace DynamicData; - -/// -/// Extensions for ObservableList. -/// -public static class ObservableListEx -{ - /// - /// Injects a side effect into a changeset stream via an . - /// The adaptor's Adapt method is invoked for each changeset before it is forwarded downstream unchanged. - /// - /// The type of items in the list. - /// The source to observe and adapt. - /// The adaptor whose Adapt method is invoked for each changeset. - /// A list changeset stream identical to the source, with the adaptor side effect applied. - /// or is . - /// - /// - /// This is the primary extension point for custom UI binding adaptors (e.g., - /// delegates to this operator). If the adaptor throws, the exception propagates downstream as OnError. - /// - /// - /// - public static IObservable> Adapt(this IObservable> source, IChangeSetAdaptor adaptor) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - adaptor.ThrowArgumentNullExceptionIfNull(nameof(adaptor)); - - return Observable.Create>( - observer => - { - var locker = InternalEx.NewLock(); - return source.Synchronize(locker).Select( - changes => - { - adaptor.Adapt(changes); - return changes; - }).SubscribeSafe(observer); - }); - } - - /// - /// Adds a key to each item in a list changeset, converting it to a cache changeset that supports all keyed DynamicData operators. - /// - /// The type of items in the list. - /// The type of the key. - /// The source to add keys to, converting to a cache changeset. - /// A function to extract a unique key from each item. - /// A cache changeset stream with keyed items. - /// or is . - /// - /// - /// All index information is dropped during conversion because cache changesets are unordered by default. - /// Use this when you need to transition from list-based pipelines to cache-based operators (Filter by key, Join, Group, etc.). - /// - /// - /// - public static IObservable> AddKey(this IObservable> source, Func keySelector) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - keySelector.ThrowArgumentNullExceptionIfNull(nameof(keySelector)); - - return source.Select(changes => new ChangeSet(new AddKeyEnumerator(changes, keySelector))); - } - - /// - /// Applies a logical AND (intersection) between multiple list changeset streams. - /// Only items present in ALL sources appear in the result. - /// - /// The type of items in the lists. - /// The first source to intersect. - /// The additional changeset streams to intersect with. - /// A list changeset stream containing items that exist in every source. - /// is . - /// - /// - /// Uses reference counting per item across all sources. An item appears downstream only when - /// its reference count is non-zero in ALL sources. Item identity is determined by the default equality comparer. - /// - /// - /// EventBehavior - /// Add/AddRangeThe item's reference count is incremented in its source tracker. If the item is now present in all sources, an Add is emitted. - /// ReplaceThe old item's reference count is decremented and the new item's is incremented. Depending on whether each is present in ALL sources, this emits an Add, Remove, Replace, or nothing. - /// Remove/RemoveRange/ClearThe item's reference count is decremented. If it was in the result and is no longer in all sources, a Remove is emitted. - /// RefreshForwarded as Refresh if the item is currently in the result. - /// MovedIgnored (set operations are position-independent). - /// - /// Worth noting: Item identity uses object equality, not position. Duplicate items in a single source are reference-counted independently. - /// - /// - /// - /// - /// - public static IObservable> And(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.And, others); - } - - /// - /// A of changeset streams to intersect. - /// - /// - /// This overload accepts a pre-built collection of sources instead of a params array. - /// - public static IObservable> And(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// An of changeset streams. Sources can be added or removed dynamically. - /// - /// - /// This overload supports dynamic source management: adding or removing changeset streams from the observable list triggers re-evaluation. - /// - public static IObservable> And(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// An of . Each inner list's changes are connected automatically. - /// - /// - /// This overload accepts instances directly, calling Connect() internally. - /// - public static IObservable> And(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// An of . Each inner list's changes are connected automatically. - /// - /// - /// This overload accepts instances directly, calling Connect() internally. - /// - public static IObservable> And(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.And); - - /// - /// Wraps a as a read-only , hiding mutation methods. - /// - /// The type of items in the list. - /// The mutable source list to wrap. - /// A read-only observable list that mirrors the source. - /// is . - public static IObservableList AsObservableList(this ISourceList source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new AnonymousObservableList(source); - } - - /// - /// Materializes a changeset stream into a read-only . - /// The list is kept in sync with the source stream for the lifetime of the subscription. - /// - /// The type of items in the list. - /// The source to materialize into a read-only list. - /// A read-only observable list reflecting the current state of the stream. - /// is . - /// - /// - /// This is the primary way to multicast a changeset pipeline. Materializing once into an , - /// then calling Connect() on the result for each downstream consumer, ensures the upstream operators are evaluated only once - /// regardless of how many subscribers consume the result. - /// - /// - /// - public static IObservableList AsObservableList(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new AnonymousObservableList(source); - } - - /// - /// Monitors all properties on each item (via ) and emits Refresh - /// changes when any property changes, causing downstream operators to re-evaluate. - /// - /// The type of items, which must implement . - /// The source to monitor for property-driven refresh signals. - /// An optional buffer duration to batch multiple refresh signals into a single changeset. - /// An optional throttle applied to each item's property change notifications. - /// The scheduler for throttle and buffer timing. Defaults to . - /// A list changeset stream with additional Refresh changes injected when properties change. - /// is . - /// - /// - /// Wraps using WhenAnyPropertyChanged() as the re-evaluator. - /// Pair with or - /// to get reactive re-evaluation on property changes. - /// - /// - /// EventBehavior - /// Add/AddRangeSubscribes to PropertyChanged on each new item. The original change is forwarded. - /// ReplaceUnsubscribes from the old item, subscribes to the new. The original change is forwarded. - /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. - /// Moved/RefreshForwarded unchanged. - /// Property changesA Refresh change is emitted for the item whose property changed. - /// - /// Worth noting: Each item generates a subscription. For large lists with frequent property changes, use and to reduce churn. - /// - /// - /// - /// - /// - public static IObservable> AutoRefresh(this IObservable> source, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.AutoRefreshOnObservable( - t => - { - if (propertyChangeThrottle is null) - { - return t.WhenAnyPropertyChanged(); - } - - return t.WhenAnyPropertyChanged().Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); - }, - changeSetBuffer, - scheduler); - } - - /// - /// Monitors a single property (selected by ) on each item via - /// and emits Refresh changes when that property changes, causing downstream operators to re-evaluate. More efficient than - /// the all-properties overload when only one property (of type ) affects downstream behavior. - /// - /// - public static IObservable> AutoRefresh(this IObservable> source, Expression> propertyAccessor, TimeSpan? changeSetBuffer = null, TimeSpan? propertyChangeThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - return source.AutoRefreshOnObservable( - t => - { - if (propertyChangeThrottle is null) - { - return t.WhenPropertyChanged(propertyAccessor, false); - } - - return t.WhenPropertyChanged(propertyAccessor, false).Throttle(propertyChangeThrottle.Value, scheduler ?? GlobalConfig.DefaultScheduler); - }, - changeSetBuffer, - scheduler); - } - - /// - /// Monitors each item with a custom observable and emits Refresh changes whenever that observable fires, - /// causing downstream operators (Filter, Sort, Group) to re-evaluate. - /// - /// The type of items in the list. - /// The type emitted by the re-evaluator observable (value is ignored). - /// The source to monitor for observable-driven refresh signals. - /// A factory that, given an item, returns an observable whose emissions trigger a Refresh for that item. - /// An optional buffer duration to batch refresh signals into a single changeset. - /// The for buffering. - /// A list changeset stream with additional Refresh changes injected when per-item observables fire. - /// or is . - /// - /// - /// This is the general-purpose refresh mechanism. - /// is a convenience wrapper that uses WhenAnyPropertyChanged() as the re-evaluator. - /// - /// - /// EventBehavior - /// Add/AddRangeSubscribes to the re-evaluator observable for each new item. The original change is forwarded. - /// ReplaceUnsubscribes from the old item's observable, subscribes to the new. The original change is forwarded. - /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. - /// Moved/RefreshForwarded unchanged. - /// Re-evaluator firesThe item's current index is looked up and a Refresh change is emitted. - /// - /// - /// - /// - /// - public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); - - return new AutoRefresh(source, reevaluator, changeSetBuffer, scheduler).Run(); - } - - /// - /// Applies changeset mutations to a target for UI data binding. - /// - /// The type of items in the list. - /// The source to bind to a collection. - /// The target collection to keep in sync. - /// When a changeset exceeds this many changes, the collection is reset instead of applying individual changes. - /// A continuation of the source changeset stream (allows further chaining). - /// or is . - /// - /// - /// Delegates to with an internal collection adaptor. - /// Each changeset is applied to the target collection on the calling thread. For UI binding, ensure the source is - /// observed on the UI thread (e.g., via ObserveOn). - /// - /// - /// EventBehavior - /// AddItem inserted at the specified index in the target collection. - /// AddRangeItems inserted as a range. If the count exceeds , the collection is cleared and repopulated. - /// ReplaceItem at the specified index is replaced. - /// RemoveItem at the specified index is removed. - /// RemoveRange/ClearItems removed from the collection. - /// MovedItem is moved between positions in the collection. - /// RefreshDepends on the adaptor implementation. - /// - /// - /// - /// - /// - /// - /// - public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - - var options = resetThreshold == BindingOptions.DefaultResetThreshold - ? defaults - : defaults with { ResetThreshold = resetThreshold }; - - return source.Bind(targetCollection, options); - } - - /// - /// Binds the source changeset stream to , with fine-grained control over reset threshold and other behaviors. - /// - /// - public static IObservable> Bind(this IObservable> source, IObservableCollection targetCollection, BindingOptions options) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - targetCollection.ThrowArgumentNullExceptionIfNull(nameof(targetCollection)); - - var adaptor = new ObservableCollectionAdaptor(targetCollection, options); - return source.Adapt(adaptor); - } - - /// - /// Constructs a and binds the changeset stream to it. - /// Use this overload when you need a read-only view (typically for UI binding) without managing the backing collection yourself. - /// The created collection is returned via the output parameter. - /// - /// - /// - /// - /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. - /// - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, int resetThreshold = BindingOptions.DefaultResetThreshold) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - // if user has not specified different defaults, use system wide defaults instead. - // This is a hack to retro fit system wide defaults which override the hard coded defaults above - var defaults = DynamicDataOptions.Binding; - var options = resetThreshold == BindingOptions.DefaultResetThreshold - ? defaults - : defaults with { ResetThreshold = resetThreshold }; - - return source.Bind(out readOnlyObservableCollection, options); - } - - /// - /// Constructs a and binds the changeset stream to it, - /// with fine-grained control over reset threshold and other behaviors. - /// The created collection is returned via the output parameter. - /// - /// - /// - /// - /// The created collection is backed by an internal ObservableCollectionExtended<T>. Callers receive only the read-only wrapper. - /// - public static IObservable> Bind(this IObservable> source, out ReadOnlyObservableCollection readOnlyObservableCollection, BindingOptions options) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - var target = new ObservableCollectionExtended(); - var result = new ReadOnlyObservableCollection(target); - var adaptor = new ObservableCollectionAdaptor(target, options); - readOnlyObservableCollection = result; - return source.Adapt(adaptor); - } - -#if SUPPORTS_BINDINGLIST - - /// - /// Binds the source changeset stream to a WinForms , keeping in sync. - /// - /// - public static IObservable> Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(this IObservable> source, BindingList bindingList, int resetThreshold = BindingOptions.DefaultResetThreshold) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - bindingList.ThrowArgumentNullExceptionIfNull(nameof(bindingList)); - - return source.Adapt(new BindingListAdaptor(bindingList, resetThreshold)); - } - -#endif - - /// - /// - /// - /// This overload starts unpaused and has no timeout. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, IScheduler? scheduler = null) - where T : notnull => BufferIf(source, pauseIfTrueSelector, false, scheduler); - - /// - /// - /// - /// This overload allows setting the initial pause state but has no timeout. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); - - return BufferIf(source, pauseIfTrueSelector, initialPauseState, null, scheduler); - } - - /// - /// - /// - /// This overload starts unpaused and accepts a timeout but not an explicit initial pause state. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, TimeSpan? timeOut, IScheduler? scheduler = null) - where T : notnull => BufferIf(source, pauseIfTrueSelector, false, timeOut, scheduler); - - /// - /// Buffers changeset notifications while a pause signal is active, then flushes all buffered changes when resumed. - /// - /// The type of items in the list. - /// The source to conditionally buffer. - /// An of that controls buffering: pauses (buffers), resumes (flushes). - /// The initial pause state. When , buffering starts immediately. - /// An optional maximum duration to keep the buffer open. After this time, the buffer is flushed regardless of pause state. - /// The for timeout scheduling. - /// A list changeset stream that buffers during pause and emits combined changesets on resume. - /// or is . - /// - /// - /// All changeset events are buffered at the changeset level (not individual changes) while paused. - /// On resume, all buffered changesets are emitted as a single combined changeset. If the buffer is empty on resume, - /// no emission occurs. - /// - /// - /// EventBehavior - /// Any (while paused)Accumulated in an internal buffer. Not emitted downstream. - /// Any (while active)Passed through immediately. - /// Pause selector emits falseAll buffered changesets are flushed downstream as one combined changeset. - /// Timeout firesAutomatically resumes and flushes the buffer. - /// OnErrorForwarded immediately (not buffered). - /// OnCompletedForwarded immediately. - /// - /// Worth noting: Each pause/resume cycle re-arms the timeout. Rapid toggling can create many small buffer windows. - /// - public static IObservable> BufferIf(this IObservable> source, IObservable pauseIfTrueSelector, bool initialPauseState, TimeSpan? timeOut, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pauseIfTrueSelector.ThrowArgumentNullExceptionIfNull(nameof(pauseIfTrueSelector)); - - return new BufferIf(source, pauseIfTrueSelector, initialPauseState, timeOut, scheduler).Run(); - } - - /// - /// Buffers changesets during an initial time window, then emits a single combined changeset and passes through subsequent changes. - /// - /// The type of items in the list. - /// The source to buffer during the initial loading period. - /// The time period (measured from first emission) during which changes are buffered. - /// The for timing the buffer window. - /// A list changeset stream where the initial burst is combined into one changeset. - /// - /// - /// For a configured duration after the first emission, all changesets are buffered and combined into a single emission. - /// After this initial window, subsequent changesets pass through immediately. - /// - /// - /// - /// - public static IObservable> BufferInitial(this IObservable> source, TimeSpan initialBuffer, IScheduler? scheduler = null) - where TObject : notnull => source.DeferUntilLoaded().Publish( - shared => - { - var initial = shared.Buffer(initialBuffer, scheduler ?? GlobalConfig.DefaultScheduler).FlattenBufferResult().Take(1); - - return initial.Concat(shared); - }); - - /// - /// Casts each item in the changeset from object to using a direct cast. - /// - /// The target type to cast to. - /// The source of object items. - /// A list changeset stream of cast items. - /// is . - /// - /// - public static IObservable> Cast(this IObservable> source) - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select(changes => changes.Transform(t => (TDestination)t)); - } - - /// - /// Transforms each item in the changeset using a conversion function. - /// - /// The source item type. - /// The destination item type. - /// The source to cast. - /// A function to convert each item from to . - /// A list changeset stream of converted items. - /// or is . - /// Use this overload when type inference requires explicit specification of both source and destination types. Alternatively, call first, then the single-type-parameter overload. - /// - /// - public static IObservable> Cast(this IObservable> source, Func conversionFactory) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); - - return source.Select(changes => changes.Transform(conversionFactory)); - } - - /// - /// Casts each item in the changeset to object. Typically used before to work around type inference limitations. - /// - /// The source item type (must be a reference type). - /// The source to cast to object. - /// A list changeset stream of object items. - /// - public static IObservable> CastToObject(this IObservable> source) - where T : class => source.Select(changes => changes.Transform(t => (object)t)); - - /// - /// Applies each changeset to the target list as a side effect, keeping it synchronized with the source. - /// - /// The type of items in the list. - /// The source to clone. - /// The target list to clone changes into. - /// A continuation of the source changeset stream. - /// is . - /// - /// Lower-level than . Uses .Clone() to apply all changeset operations directly. - /// - /// - /// - public static IObservable> Clone(this IObservable> source, IList target) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Do(target.Clone); - } - - /// - /// Convert the object using the specified conversion function. - /// This is a lighter equivalent of Transform and is designed to be used with non-disposable objects. - /// - /// The type of items in the list. - /// The type of the destination items. - /// The source to convert. - /// The conversion factory. - /// An observable which emits the change set. - [Obsolete("Prefer Cast as it is does the same thing but is semantically correct")] - public static IObservable> Convert(this IObservable> source, Func conversionFactory) - where TObject : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - conversionFactory.ThrowArgumentNullExceptionIfNull(nameof(conversionFactory)); - - return source.Select(changes => changes.Transform(conversionFactory)); - } - - /// - /// Defers downstream delivery until the source emits its first changeset, then forwards all subsequent changesets. - /// - /// The type of the object. - /// The source to defer until the first changeset arrives. - /// A list changeset stream that begins emitting only after the source has produced its first changeset. - /// is . - /// - /// - /// Subscribes to the source immediately but buffers internally until the first changeset arrives, at which point it emits - /// the initial data and all subsequent changesets. This is useful when downstream consumers should not receive an empty initial state. - /// - /// - /// - /// - public static IObservable> DeferUntilLoaded(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DeferUntilLoaded(source).Run(); - } - - /// - /// - /// - /// Convenience overload that calls source.Connect().DeferUntilLoaded(). - /// - public static IObservable> DeferUntilLoaded(this IObservableList source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().DeferUntilLoaded(); - } - - /// - /// Disposes items that implement when they are removed, replaced, or cleared from the stream. - /// All remaining tracked items are disposed when the stream finalizes (OnCompleted, OnError, or subscription disposal). - /// - /// The type of the object. - /// The source to track for disposal on removal. - /// A continuation of the source changeset stream with disposal side effects applied. - /// is . - /// - /// - /// Items are cast to and disposed after the changeset has been forwarded downstream. - /// Items that do not implement are silently ignored. - /// - /// - /// EventBehavior - /// Add/AddRangeItems are tracked for future disposal. Changeset forwarded. - /// ReplaceThe previous (replaced) item is disposed after the changeset is forwarded. The new item is tracked. - /// Remove/RemoveRangeRemoved items are disposed after the changeset is forwarded. - /// ClearAll tracked items are disposed after the changeset is forwarded. - /// Moved/RefreshForwarded. No disposal occurs. - /// OnError/OnCompleted/DisposalAll remaining tracked items are disposed during finalization. - /// - /// Worth noting: Disposal happens after the changeset is delivered downstream, so subscribers see the change before items are disposed. - /// - /// - /// - /// - public static IObservable> DisposeMany(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new DisposeMany(source).Run(); - } - - /// - /// Extracts distinct values from source items using , with reference counting to track when values enter and leave the result set. - /// - /// The type of items in the source list. - /// The type of distinct values produced. - /// The source to extract distinct values. - /// A function that extracts the value to track from each source item. - /// A list changeset stream of distinct values. - /// or is . - /// - /// - /// Maintains an internal reference count per distinct value. A value is included when its count first exceeds zero - /// and removed when its count drops back to zero. - /// - /// - /// EventBehavior - /// Add/AddRangeValue extracted. If first occurrence, an Add is emitted. Otherwise the reference count is incremented silently. - /// ReplaceOld value's reference count decremented (removed if zero), new value's count incremented (added if first). If the value did not change, no emission. - /// Remove/RemoveRangeReference count decremented. If the count reaches zero, a Remove is emitted for that distinct value. - /// RefreshValue is re-extracted. If changed, old value decremented and new value incremented (same as Replace logic). - /// ClearAll reference counts cleared. Remove emitted for every tracked distinct value. - /// - /// - /// - public static IObservable> DistinctValues(this IObservable> source, Func valueSelector) - where TObject : notnull - where TValue : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - valueSelector.ThrowArgumentNullExceptionIfNull(nameof(valueSelector)); - - return new Distinct(source, valueSelector).Run(); - } - - /// - /// Applies a logical set-difference (Except) between the source and other streams. - /// Items present in the first source but not in any of the are included in the result. - /// - /// The type of the item. - /// The primary from which other streams are subtracted. - /// The other changeset streams to exclude from the result. - /// A list changeset stream containing items from that are not in any of . - /// is . - /// - /// - /// Item identity is determined by the default equality comparer for . Across all sources, items are tracked - /// by reference-counted equality (not by index position). - /// The first source has a special role: only items from it can appear in the result, and only if they do not exist in any other source. - /// - /// - /// EventBehavior - /// Add/AddRange (first source)If the item does not exist in any other source, an Add is emitted. - /// Add/AddRange (other source)If the item was in the result (from first source), a Remove is emitted. - /// Remove/RemoveRange/Clear (first source)If the item was in the result, a Remove is emitted. - /// Remove/RemoveRange/Clear (other source)If the item exists in the first source and no longer in any other, an Add is emitted. - /// ReplaceTreated as a Remove of the old item plus an Add of the new item, with set logic re-evaluated. - /// MovedIgnored by the set logic (no positional semantics). - /// RefreshForwarded if the item is currently in the result set. - /// - /// Worth noting: Unlike , the first source is asymmetric: only its items can appear in the result. - /// - /// - /// - /// - /// - public static IObservable> Except(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.Except, others); - } - - /// - /// - /// - /// Static overload accepting a pre-built collection of sources. The first item in the collection is the primary source. - /// - public static IObservable> Except(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// - /// - /// Dynamic overload: sources can be added or removed from the at runtime. The first source in the list acts as the primary. - /// - public static IObservable> Except(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// - /// - /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Except(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// - /// - /// Dynamic overload accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Except(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Except); - - /// - /// Automatically removes items from the list after the duration returned by . - /// Returns an observable of the items that were expired and removed. - /// - /// The type of the item. - /// The source list to apply time-based expiration to. - /// A function returning the time-to-live for each item. Return for items that should never expire. - /// An optional polling interval to batch expiry checks. If omitted, a separate timer is created for each unique expiry time. - /// The scheduler for scheduling expiry timers. Defaults to . - /// An observable that emits collections of items each time expired items are removed from the source list. - /// - /// - /// This operator acts directly on an , not on a changeset stream. It monitors items as they are added, - /// schedules their removal, and physically removes them from the source list when their time expires. - /// - /// - /// When is specified, all items due for removal are batched into a single removal at each polling tick, - /// which can improve performance when many items expire around the same time. - /// - /// Worth noting: The returned observable emits the expired items (not changesets). Subscribe to this observable to trigger the expiry mechanism; if not subscribed, no items will be removed. - /// - /// - /// - public static IObservable> ExpireAfter( - this ISourceList source, - Func timeSelector, - TimeSpan? pollingInterval = null, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ExpireAfter.Create( - source: source, - timeSelector: timeSelector, - pollingInterval: pollingInterval, - scheduler: scheduler); - - /// - /// Filters items from the source list changeset stream using a static predicate. - /// Only items satisfying are included downstream. - /// - /// The type of items in the list. - /// The source to filter. - /// A predicate that determines which items are included. Items returning appear downstream; items returning are excluded. - /// A list changeset stream containing only items that satisfy . - /// Thrown when or is . - /// - /// - /// Use this overload when you need only a single predicate function for the lifetime of the subscription; - /// unlike the dynamic-predicate and state-driven overloads, the predicate function itself never changes. - /// Note that this does not mean an item's inclusion is fixed: Refresh events can re-evaluate each item against the predicate - /// and promote a previously-excluded item to included (or vice versa). - /// Item ordering is preserved. - /// - /// - /// EventBehavior - /// AddThe predicate is evaluated. If the item passes, an Add is emitted at the calculated downstream index. Otherwise dropped. - /// AddRangeEach item in the range is evaluated. Matching items are emitted as an AddRange. - /// ReplaceThe predicate is re-evaluated. Four outcomes: both pass produces Replace; new passes but old didn't produces Add; old passed but new doesn't produces Remove; neither passes is dropped. - /// RemoveIf the item was included downstream, a Remove is emitted. Otherwise dropped. - /// RemoveRangeIncluded items in the range are emitted as individual Remove changes. - /// RefreshThe predicate is re-evaluated. If the item now passes but previously did not, an Add is emitted. If it previously passed but no longer does, a Remove is emitted. If still passes, the Refresh is forwarded. If still fails, dropped. - /// ClearAll downstream items are cleared. - /// - /// Worth noting: Refresh events trigger re-evaluation, which can promote or demote items (turning a Refresh into an Add or Remove). Pair with for property-change-driven filtering. - /// - /// - /// - /// - /// - public static IObservable> Filter( - this IObservable> source, - Func predicate) - where T : notnull - => List.Internal.Filter.Static.Create( - source: source, - predicate: predicate, - suppressEmptyChangesets: true); - - /// - /// Filters items using a dynamically changing predicate. - /// When emits a new function, all items are re-evaluated. - /// - /// The type of the item. - /// The source to filter. - /// An that emits new predicate functions. Each emission triggers a full re-evaluation of all items. - /// The that controls re-filtering behavior when the predicate changes. - /// A list changeset stream containing only items that satisfy the most recent predicate. - /// - /// - /// Each time emits, every item is re-evaluated against the new predicate. - /// - /// - /// EventBehavior - /// AddThe current predicate is evaluated. If the item passes, an Add is emitted. Otherwise dropped. - /// AddRangeEach item is evaluated. Matching items are emitted as AddRange. - /// ReplaceRe-evaluated. Same four-outcome logic as the static overload (Replace, Add, Remove, or dropped). - /// RemoveIf the item was downstream, a Remove is emitted. Otherwise dropped. - /// RefreshRe-evaluated. If inclusion status changed, an Add or Remove is emitted. If unchanged, Refresh forwarded or dropped. - /// ClearAll downstream items are cleared. - /// Predicate changedAll items are re-evaluated against the new predicate. The output is shaped by . - /// OnCompletedIndependent completion of does not terminate the filter. - /// - /// Worth noting: No items are included until emits its first function. - /// - /// or is . - /// - /// - public static IObservable> Filter(this IObservable> source, IObservable> predicate, ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); - - return new List.Internal.Filter.Dynamic(source, predicate, filterPolicy).Run(); - } - - /// - /// Filters items using a predicate that receives external state. When emits a new state value, - /// all items are re-evaluated against using the updated state. - /// - /// The type of the item. - /// The type of state value required by . - /// The source to filter. - /// An stream of state values to be passed to . - /// A static predicate receiving the current state and an item, returning to include or to exclude. The function itself does not change; only the state value passed to it changes. - /// The that controls re-filtering behavior when the state changes. - /// When (default), empty changesets are suppressed. Set to to publish empty changesets (useful for monitoring loading status). - /// A list changeset stream containing only items satisfying with the current state. - /// , , or is . - /// - /// - /// The predicate cannot be invoked until the first state value is received. Until then, all items are treated as excluded. - /// Each subsequent state emission triggers a full re-evaluation of all items according to . - /// - /// - /// EventBehavior - /// Add/AddRangeEvaluated using current state. Matching items emitted as Add/AddRange. - /// ReplaceRe-evaluated. Same four-outcome logic as the static filter (Replace, Add, Remove, or dropped). - /// Remove/RemoveRangeIf the item was downstream, a Remove is emitted. - /// RefreshRe-evaluated against current state. Inclusion status may change. - /// ClearAll downstream items are cleared. - /// State changedAll items are re-evaluated with the new state value. The output is shaped by . - /// - /// - /// - /// - public static IObservable> Filter( - this IObservable> source, - IObservable predicateState, - Func predicate, - ListFilterPolicy filterPolicy = ListFilterPolicy.CalculateDiff, - bool suppressEmptyChangeSets = true) - where T : notnull - => List.Internal.Filter.WithPredicateState.Create( - source: source, - predicateState: predicateState, - predicate: predicate, - filterPolicy: filterPolicy, - suppressEmptyChangeSets: suppressEmptyChangeSets); - - /// - /// Filters each item using a per-item of that dynamically controls inclusion. - /// When an item's observable emits the item enters the result; when it emits the item is removed. - /// - /// The type of items in the list. - /// The source to filter by property value. - /// A function that returns an observable of for each item, controlling its inclusion. - /// An optional throttle duration applied to each per-item observable to reduce re-evaluation frequency. - /// The used when throttling. Defaults to the system default scheduler. - /// A list changeset stream containing only items whose per-item observable most recently emitted . - /// or is . - /// - /// - /// Each item in the source gets its own subscription to the observable returned by . - /// The item's inclusion is determined by the most recent boolean value emitted by that observable. - /// - /// - /// Event (source)Behavior - /// Add/AddRangeSubscribes to the per-item observable. Item is included when it first emits . - /// ReplaceOld subscription disposed, new subscription created for the replacement item. - /// Remove/RemoveRange/ClearSubscription disposed. If the item was downstream, a Remove is emitted. - /// RefreshForwarded if the item is currently included. - /// - /// - /// Event (per-item observable)Behavior - /// Emits If not already included, an Add is emitted downstream. - /// Emits If currently included, a Remove is emitted downstream. - /// - /// - /// - /// - /// - /// - public static IObservable> FilterOnObservable(this IObservable> source, Func> objectFilterObservable, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new FilterOnObservable(source, objectFilterObservable, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Filters items based on a property value, automatically re-evaluating when the specified property changes on any item. - /// - /// The type of the object. Must implement . - /// The type of the property. - /// The source to filter by property value. - /// selecting the property to monitor for changes. - /// A predicate evaluated against the item to determine inclusion. - /// An optional throttle duration for property change notifications. - /// The used when throttling. - /// A list changeset stream of items satisfying the predicate, re-evaluated on property changes. - /// - /// Deprecated. Use followed by instead. - /// - /// - /// - [Obsolete("Use AutoRefresh(), followed by Filter() instead")] - public static IObservable> FilterOnProperty(this IObservable> source, Expression> propertySelector, Func predicate, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - predicate.ThrowArgumentNullExceptionIfNull(nameof(predicate)); - - return new FilterOnProperty(source, propertySelector, predicate, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Flattens buffered changesets (e.g. from ) back into single changesets. - /// Empty buffers are dropped. - /// - /// The type of the item. - /// The of buffered changeset lists. - /// A list changeset stream with all buffered changes concatenated into single changesets. - /// - /// Use this after applying Observable.Buffer() to a changeset stream to re-merge the batched changesets into a single stream. - /// - /// - /// - public static IObservable> FlattenBufferResult(this IObservable>> source) - where T : notnull => source.Where(x => x.Count != 0).Select(updates => new ChangeSet(updates.SelectMany(u => u))); - - /// - /// Invokes once for every in each changeset. Range changes - /// (AddRange, RemoveRange, Clear) are delivered as a single ; they are not flattened into per-item changes. - /// The changeset is forwarded downstream unchanged. - /// - /// The type of items in the list. - /// The source to observe each change in. - /// The action invoked for each . - /// A continuation of the source changeset stream. - /// or is . - /// - /// This is a side-effect operator. It does not modify the changeset. If you need each individual item from range operations flattened out, use instead. - /// - /// EventBehavior - /// Add/Replace/Remove/Moved/RefreshCallback invoked with the (single-item change). Changeset forwarded. - /// AddRange/RemoveRange/ClearCallback invoked once with the containing the range (accessible via Range property). Changeset forwarded. - /// OnErrorIf the callback throws, the exception propagates as OnError. - /// - /// - /// - /// - /// - /// - public static IObservable> ForEachChange(this IObservable> source, Action> action) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - action.ThrowArgumentNullExceptionIfNull(nameof(action)); - - return source.Do(changes => changes.ForEach(action)); - } - - /// - /// Invokes for every individual in each changeset. - /// Range changes are flattened into individual item changes first, so the callback only receives Add, Replace, Remove, and Refresh. - /// - /// The type of items in the list. - /// The source to observe each item-level change in. - /// The action invoked for each individual item change. - /// A continuation of the source changeset stream. - /// or is . - /// - /// - /// Unlike , this operator flattens - /// AddRange, RemoveRange, and Clear into individual entries before invoking the callback. - /// - /// - /// - public static IObservable> ForEachItemChange(this IObservable> source, Action> action) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - action.ThrowArgumentNullExceptionIfNull(nameof(action)); - - return source.Do(changes => changes.Flatten().ForEach(action)); - } - - /// - /// Groups source items by the value returned by . Each group is an - /// containing an inner observable list of its members. - /// - /// The type of items in the list. - /// The type of the group key. - /// The source to group. - /// A function that returns the group key for each item. - /// An optional of that forces all items to be re-evaluated against when it fires. Useful for time-based groupings (e.g., "Last Hour", "Today"). - /// A list changeset stream of objects, each containing the items belonging to that group. - /// or is . - /// - /// - /// Groups are created lazily and removed when empty. Each group exposes an inner observable list that receives incremental updates. - /// - /// - /// EventBehavior - /// Add/AddRangeGroup key evaluated. Item added to its group. If the group is new, an Add of the group is emitted. - /// ReplaceGroup key re-evaluated. If the group changed, the item is removed from the old group and added to the new one. Empty old groups are removed. - /// Remove/RemoveRange/ClearItem removed from its group. Empty groups are removed from the result. - /// RefreshGroup key re-evaluated. If changed, the item moves between groups. - /// MovedNot handled by group logic. - /// Regrouper firesAll items re-evaluated. Items that changed group key are moved between groups. Empty groups removed, new groups added. - /// - /// - /// - /// - /// - public static IObservable>> GroupOn(this IObservable> source, Func groupSelector, IObservable? regrouper = null) - where TObject : notnull - where TGroup : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - groupSelector.ThrowArgumentNullExceptionIfNull(nameof(groupSelector)); - - return new GroupOn(source, groupSelector, regrouper).Run(); - } - - /// - /// Groups items by a property value, automatically re-grouping when the specified property changes on any item. - /// Each group contains an inner observable list. - /// - /// The type of the object. Must implement . - /// The type of the group key. - /// The source to group by property value. - /// selecting the property whose value determines the group key. - /// An optional throttle duration for property change notifications. - /// The used when throttling. - /// A list changeset stream of objects. - /// or is . - /// - /// - /// Convenience operator equivalent to .AutoRefresh(propertySelector).GroupOn(item => property). - /// Property changes trigger re-evaluation of the group key, potentially moving items between groups. - /// - /// - /// - /// - /// - public static IObservable>> GroupOnProperty(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TGroup : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - return new GroupOnProperty(source, propertySelector, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Groups items by a property value, automatically re-grouping when the specified property changes. - /// Each group emits immutable snapshots (not live observable lists). - /// - /// The type of the object. Must implement . - /// The type of the group key. - /// The source to group by property value with immutable snapshots. - /// selecting the property whose value determines the group key. - /// An optional throttle duration for property change notifications. - /// The used when throttling. - /// A list changeset stream of immutable group snapshots. - /// or is . - /// - /// - /// Combines - /// with . - /// Unlike , - /// this produces immutable snapshots per group rather than live inner observable lists. - /// - /// - /// - /// - public static IObservable>> GroupOnPropertyWithImmutableState(this IObservable> source, Expression> propertySelector, TimeSpan? propertyChangedThrottle = null, IScheduler? scheduler = null) - where TObject : INotifyPropertyChanged - where TGroup : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - propertySelector.ThrowArgumentNullExceptionIfNull(nameof(propertySelector)); - - return new GroupOnPropertyWithImmutableState(source, propertySelector, propertyChangedThrottle, scheduler).Run(); - } - - /// - /// Groups source items by the value returned by . Each update produces immutable grouping snapshots - /// rather than live inner observable lists. - /// - /// The type of items in the list. - /// The type of the group key. - /// The source to group with immutable snapshots. - /// A function that returns the group key for each item. - /// An optional of that forces all items to be re-evaluated when it fires. - /// A list changeset stream of immutable snapshots. - /// or is . - /// - /// - /// Works like - /// but each affected group emits a new immutable snapshot on every change rather than updating a live inner list. - /// This is useful when consumers need thread-safe, point-in-time snapshots of each group. - /// - /// - /// - /// - public static IObservable>> GroupWithImmutableState(this IObservable> source, Func groupSelectorKey, IObservable? regrouper = null) - where TObject : notnull - where TGroupKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - groupSelectorKey.ThrowArgumentNullExceptionIfNull(nameof(groupSelectorKey)); - - return new GroupOnImmutable(source, groupSelectorKey, regrouper).Run(); - } - - /// - /// Limits the source list to a maximum number of items using FIFO eviction. - /// When the list exceeds , the oldest items are removed. - /// Returns an observable of the items that were removed. - /// - /// The type of the item. - /// The source list to apply size limits to. - /// The maximum number of items allowed. Must be greater than zero. - /// The scheduler for scheduling size checks. Defaults to . - /// An observable that emits collections of items each time excess items are removed from the source list. - /// is . - /// is zero or negative. - /// - /// - /// This operator acts directly on an . It subscribes to the source's changes, - /// tracks insertion order using an internal Transform, and removes the oldest items when the size limit is exceeded. - /// - /// Worth noting: The returned observable emits the removed items (not changesets). Subscribe to this observable to activate the size-limiting mechanism. Removal is performed synchronously under a lock shared with the change tracking. - /// - /// - /// - public static IObservable> LimitSizeTo(this ISourceList source, int sizeLimit, IScheduler? scheduler = null) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (sizeLimit <= 0) - { - throw new ArgumentException("sizeLimit cannot be zero", nameof(sizeLimit)); - } - - var locker = InternalEx.NewLock(); - var limiter = new LimitSizeTo(source, sizeLimit, scheduler ?? GlobalConfig.DefaultScheduler, locker); - - return limiter.Run().Synchronize(locker).Do(source.RemoveMany); - } - - /// - /// Subscribes to a per-item observable for each item in the source and merges all emissions into a single stream. - /// This is NOT a changeset operator: it returns a flat observable of values. - /// - /// The type of items in the source list. - /// The type of values emitted by per-item observables. - /// The source whose items each produce an observable. - /// A function that returns an observable for each source item. - /// An observable that emits values from all per-item observables, merged together. - /// or is . - /// - /// - /// Event (source)Subscription behavior - /// Add/AddRangeSubscribes to the per-item observable. Emissions are merged into the output. - /// ReplaceOld subscription disposed, new subscription created for the replacement item. - /// Remove/RemoveRange/ClearSubscription disposed. - /// Refresh/MovedNo effect on subscriptions. - /// OnCompleted (source)Completes only after the source and all active inner observables have completed. - /// - /// - /// - /// - /// - /// - public static IObservable MergeMany(this IObservable> source, Func> observableSelector) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeMany(source, observableSelector).Run(); - } - - /// - /// - /// Merges multiple list changeset streams from an observable-of-observables into a single unified changeset stream. - /// Unlike , list merging performs no key-based deduplication. - /// - /// The source of nested changeset observables. - /// An optional used by the merge tracker to compare items. - public static IObservable> MergeChangeSets(this IObservable>> source, IEqualityComparer? equalityComparer = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new MergeChangeSets(source, equalityComparer).Run(); - } - - /// - /// - /// Merges two list changeset streams into a single unified stream. - /// - /// The first to merge. - /// The second to merge with. - /// An optional used to compare items. - /// An optional for scheduling enumeration. - /// When (default), the result completes when all sources complete. - public static IObservable> MergeChangeSets(this IObservable> source, IObservable> other, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - other.ThrowArgumentNullExceptionIfNull(nameof(other)); - - return new[] { source, other }.MergeChangeSets(equalityComparer, scheduler, completable); - } - - /// - /// - /// Merges the source list changeset stream with additional changeset streams into a single unified stream. - /// - /// The primary source to merge. - /// The additional of list changeset streams to merge with. - /// An optional used to compare items. - /// An optional for scheduling enumeration. - /// When (default), the result completes when all sources complete. - public static IObservable> MergeChangeSets(this IObservable> source, IEnumerable>> others, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.EnumerateOne().Concat(others).MergeChangeSets(equalityComparer, scheduler, completable); - } - - /// - /// Merges a collection of list changeset streams into a single unified changeset stream. - /// This is the canonical list MergeChangeSets overload: other overloads accepting , , or pair/params variants ultimately produce equivalent behavior. - /// - /// The type of items in the list. - /// The collection of list changeset streams to merge. - /// An optional used by the merge tracker to compare items. Defaults to when . - /// An optional for scheduling enumeration. - /// When (default), the result completes when all sources complete. - /// A single list changeset stream containing all changes from all sources. - /// is . - /// - /// - /// All changes from inner streams are forwarded to the output. There is no key-based deduplication (unlike ): if the same item appears in multiple inner streams, it will appear multiple times in the merged output. - /// - /// - /// EventBehavior - /// Add/AddRangeForwarded to the merged output. - /// ReplaceThe old value is replaced by the new value in the merged output. If the old value is not found (by ), the new value is added instead. - /// Remove/RemoveRange/ClearForwarded to the merged output. - /// RefreshForwarded to the merged output. - /// MovedIgnored. - /// - /// - /// - /// - /// - /// - public static IObservable> MergeChangeSets(this IEnumerable>> source, IEqualityComparer? equalityComparer = null, IScheduler? scheduler = null, bool completable = true) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new MergeChangeSets(source, equalityComparer, completable, scheduler).Run(); - } - - /// - /// - /// Merges list changeset streams from an into a single stream. Sources can be added or removed dynamically. - /// - public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().MergeChangeSets(equalityComparer); - } - - /// - /// - /// Merges list changeset streams from a list-of-list-changeset-observables into a single stream. - /// Each inner list changeset observable in the source list is merged, and parent item removal triggers child cleanup. - /// - public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeManyChangeSets(static src => src, equalityComparer); - } - - /// - /// Merges cache changeset streams from an into a single cache changeset stream. - /// Uses to resolve conflicts when the same key appears in multiple child streams. - /// - /// The type of items in the list. - /// The type of the object key. - /// The of cache changeset observables. - /// to resolve which value wins when the same key appears in multiple sources. - /// A single cache changeset stream with key-based deduplication. - /// is . - /// - /// Sources can be added or removed dynamically from the observable list. Parent item removal triggers cleanup of all child items from that source. - /// - /// EventBehavior - /// Add (child)If the destination key is new, an Add is emitted. If another source already contributed a child with the same key, resolves the conflict (lowest-ordered value wins). The losing value is tracked internally but not emitted. - /// Update (child)If this source currently owns the destination key downstream, an Update is emitted. Otherwise re-evaluates all sources; a different source's value may win, producing an Update to that value instead. - /// Remove (child)If this source's value was the one published downstream for that destination key, the operator scans other sources for the same key. If found, an Update is emitted with the replacement (per ). Otherwise a Remove is emitted. - /// Refresh (child)If the child item is the one currently published downstream, the Refresh is forwarded. Otherwise re-evaluates all sources; if a different value now wins, an Update is emitted instead. - /// Source list AddSubscribes to the new child changeset stream and merges its keys into the output. - /// Source list RemoveDisposes that source's subscription. All keys it contributed are removed. For keys also contributed by other sources, the next-best value (per ) is promoted as an Update, not an Add. - /// - /// - /// - /// - public static IObservable> MergeChangeSets(this IObservableList>> source, IComparer comparer) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().MergeChangeSets(comparer); - } - - /// - /// - /// Merges cache changeset streams from an into a single cache changeset stream, with optional equality and ordering comparers. - /// - /// The of cache changeset observables. - /// An optional to determine if two elements are the same. - /// An optional to resolve conflicts when the same key appears in multiple sources. - public static IObservable> MergeChangeSets(this IObservableList>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Connect().MergeChangeSets(equalityComparer, comparer); - } - - /// - /// - /// Merges cache changeset streams from a list changeset of cache changeset observables, using a comparer for conflict resolution. - /// - /// The source whose items are cache changeset observables. - /// to resolve which value wins when the same key appears in multiple sources. - public static IObservable> MergeChangeSets(this IObservable>>> source, IComparer comparer) - where TObject : notnull - where TKey : notnull - { - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.MergeChangeSets(comparer); - } - - /// - /// - /// Merges cache changeset streams from a list changeset of cache changeset observables, with optional equality and ordering comparers. - /// - /// The source whose items are cache changeset observables. - /// An optional to determine if two elements are the same. - /// An optional to resolve conflicts when the same key appears in multiple sources. - public static IObservable> MergeChangeSets(this IObservable>>> source, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeManyChangeSets(static src => src, equalityComparer, comparer); - } - - /// - /// Transforms each source item into a child list changeset stream using , - /// then merges all child streams into a single flat list changeset stream. Parent item removal cleans up all associated children. - /// - /// The type of items in the source list. - /// The type of items in the child changeset streams. - /// The source whose items each produce a child changeset stream. - /// A function that returns a child list changeset stream for each source item. - /// An optional used to compare child items. - /// A single list changeset stream containing all items from all child streams. - /// or is . - /// - /// - /// Internally subscribes to each child stream when a source item is added and disposes the subscription when it is removed. - /// All child items from a removed parent are removed from the merged output. - /// - /// - /// Event (source)Behavior - /// Add/AddRangeSubscribes to the child stream. Child emissions are merged into the output. - /// ReplaceOld child subscription disposed (and its items removed from output). New child subscription created. - /// Remove/RemoveRange/ClearChild subscription disposed. All child items from that parent are removed. - /// - /// - /// - /// - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null) - where TObject : notnull - where TDestination : notnull - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (observableSelector == null) - { - throw new ArgumentNullException(nameof(observableSelector)); - } - - return new MergeManyListChangeSets(source, observableSelector, equalityComparer).Run(); - } - - /// - /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. - /// Uses to resolve key conflicts when the same key appears in multiple child streams. - /// - /// The type of items in the source list. - /// The type of items in the child cache changeset streams. - /// The type of the key in the child cache changesets. - /// The source whose items each produce a child changeset stream. - /// A function that returns a child cache changeset stream for each source item. - /// to resolve which value wins when the same key appears from multiple children. - /// A single cache changeset stream with key-based deduplication. - /// , , or is . - /// - /// - /// Delegates to with a equality comparer. - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IComparer comparer) - where TObject : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return source.MergeManyChangeSets(observableSelector, equalityComparer: null, comparer: comparer); - } - - /// - /// Transforms each source item into a child cache changeset stream and merges all children into a single cache changeset stream. - /// This is the primary list-to-cache MergeManyChangeSets overload. - /// - /// The type of items in the source list. - /// The type of items in the child cache changeset streams. - /// The type of the key in the child cache changesets. - /// The source whose items each produce a child changeset stream. - /// A function that returns a child cache changeset stream for each source item. - /// An optional to determine if two elements are the same. - /// An optional to resolve conflicts when the same key appears from multiple children. - /// A single cache changeset stream with key-based deduplication. - /// or is . - /// - /// - /// Each source item produces a keyed child stream via . All child items are tracked by key. - /// When a parent item is removed, all its child items are removed from the merged output. - /// When the same key appears from multiple children, determines which value wins. - /// - /// - /// Event (source)Behavior - /// Add/AddRangeSubscribes to the child cache stream. Child key/value pairs are merged into the output cache. - /// ReplaceOld child subscription disposed (and its keys removed from output). New child subscription created. - /// Remove/RemoveRange/ClearChild subscription disposed. All keys originating from that child are removed from the output. - /// Moved/RefreshIgnored; this operator emits a cache changeset and source ordering/refresh does not affect key membership. - /// - /// - /// Error and completion: - /// - /// - /// EventBehavior - /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. - /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. - /// - /// - /// - /// - public static IObservable> MergeManyChangeSets(this IObservable> source, Func>> observableSelector, IEqualityComparer? equalityComparer = null, IComparer? comparer = null) - where TObject : notnull - where TDestination : notnull - where TDestinationKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); - } - - /// - /// Suppresses empty changesets from the stream. Only changesets with at least one change are forwarded. - /// - /// The type of the item. - /// The source to suppress empty changesets. - /// A list changeset stream with empty changesets filtered out. - /// is . - /// - /// - public static IObservable> NotEmpty(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Where(s => s.Count != 0); - } - - /// - /// Invokes for every item added to the source list stream. - /// Triggers on , , and the new item of . - /// - /// The type of items in the list. - /// The source to observe item additions in. - /// The action to invoke for each added item. - /// A continuation of the source changeset stream, with the side effect applied before forwarding. - /// or is . - /// - /// The action fires before the changeset is forwarded downstream. - /// - /// EventBehavior - /// AddCallback invoked with the added item. Changeset forwarded. - /// AddRangeCallback invoked for each item in the range. Changeset forwarded. - /// ReplaceCallback invoked for the new (replacement) item. Changeset forwarded. - /// Remove/RemoveRange/ClearNo callback. Changeset forwarded. - /// Moved/RefreshNo callback. Changeset forwarded. - /// OnErrorIf the callback throws, the exception propagates as OnError. - /// - /// - /// - /// - /// - /// - public static IObservable> OnItemAdded( - this IObservable> source, - Action addAction) - where T : notnull - => List.Internal.OnItemAdded.Create( - source: source, - addAction: addAction); - - /// - /// Invokes for every item with a change in the source stream. - /// - /// The type of items in the list. - /// The source to observe item refresh events in. - /// The action to invoke for each refreshed item. - /// A continuation of the source changeset stream, with the side effect applied before forwarding. - /// or is . - /// - /// - /// - /// - public static IObservable> OnItemRefreshed( - this IObservable> source, - Action refreshAction) - where T : notnull - => List.Internal.OnItemRefreshed.Create( - source: source, - refreshAction: refreshAction); - - /// - /// Invokes for every item removed from the source list stream. - /// Triggers on , , , and the old item of . - /// - /// The type of items in the list. - /// The source to observe item removals in. - /// The action to invoke for each removed item. - /// When (default), is also invoked for all remaining tracked items upon stream disposal, completion, or error. - /// A continuation of the source changeset stream, with the side effect applied before forwarding. - /// or is . - /// - /// - /// When is , the operator tracks all items that have been added but not yet removed, - /// and fires for each of them during finalization. This is useful for resource cleanup patterns. - /// - /// - /// EventBehavior - /// Add/AddRangeTracked internally (when is ). No callback invoked. Changeset forwarded. - /// ReplaceCallback invoked for the previous (replaced) item. New item tracked. Changeset forwarded. - /// RemoveCallback invoked for the removed item. Changeset forwarded. - /// RemoveRange/ClearCallback invoked for each removed item. Changeset forwarded. - /// Moved/RefreshNo callback. Changeset forwarded. - /// OnErrorIf is , callback is invoked for all tracked items before the error propagates. - /// OnCompletedIf is , callback is invoked for all tracked items before completion propagates. - /// - /// Worth noting: When is (the default), disposing the subscription also invokes the callback for every item still in the list, not just items that were explicitly removed during the subscription. Exceptions in are not caught. - /// - /// - /// - /// - /// - public static IObservable> OnItemRemoved( - this IObservable> source, - Action removeAction, - bool invokeOnUnsubscribe = true) - where T : notnull - => List.Internal.OnItemRemoved.Create( - source: source, - removeAction: removeAction, - invokeOnUnsubscribe: invokeOnUnsubscribe); - - /// - /// - /// Applies a logical OR (union) between a pre-built collection of list changeset sources. Items present in any source are included. - /// - /// - public static IObservable> Or(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// Applies a logical OR (union) between the source and other list changeset streams. - /// Items present in any of the sources are included in the result, using reference-counted equality. - /// - /// The type of the item. - /// The primary source to union. - /// The other changeset streams to combine with. - /// A list changeset stream containing items that exist in at least one source. - /// is . - /// - /// - /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it first appears in any source and removed when it no longer exists in any source. - /// Moved changes are ignored by the set logic. - /// - /// - /// EventBehavior - /// Add/AddRange (any source)If the item is new to the result, an Add is emitted. Otherwise the reference count is incremented. - /// Remove/RemoveRange/Clear (any source)Reference count decremented. If count reaches zero, a Remove is emitted. - /// ReplaceOld item reference count decremented, new item reference count incremented. Add/Remove emitted as needed. - /// RefreshForwarded if the item is in the result set. - /// MovedIgnored. - /// - /// - /// - /// - /// - /// - public static IObservable> Or(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.Or, others); - } - - /// - /// - /// Dynamic OR: sources can be added or removed from the at runtime. - /// - public static IObservable> Or(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// - /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Or(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// - /// Dynamic OR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Or(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Or); - - /// - /// Applies page-based windowing to the source list. Only items within the current page (determined by page number and page size from ) are included downstream. - /// - /// The type of the item. - /// The source to page. - /// An observable of controlling which page to display (page number and page size). - /// An stream containing only items within the current page window. - /// or is . - /// - /// - /// Maintains the full source list internally and calculates the page window on each change or page request. - /// Items entering the page window produce Add; items leaving produce Remove. A new page request triggers - /// a full recalculation of the page contents. - /// - /// Worth noting: Duplicate items are removed from the result via Distinct() using the default equality comparer for , regardless of source order. The source should ideally be sorted before paging, since list order determines which items fall within each page window. - /// - /// - /// - /// - public static IObservable> Page(this IObservable> source, IObservable requests) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); - - return new Pager(source, requests).Run(); - } - - /// - /// Subscribes to the source changeset stream and pipes all changes into the . - /// - /// The type of the object. - /// The source to pipe into a target list. - /// The destination to receive all changes. - /// An representing the subscription. Dispose to stop piping changes. - /// or is . - /// - /// Each changeset is applied to the destination using Clone() inside an Edit() call, producing a single batch update per changeset. - /// - /// - /// - /// - public static IDisposable PopulateInto(this IObservable> source, ISourceList destination) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - destination.ThrowArgumentNullExceptionIfNull(nameof(destination)); - - return source.Subscribe(changes => destination.Edit(updater => updater.Clone(changes))); - } - - /// - /// Emits a projected value from the current list snapshot after every changeset. - /// The receives an representing the current state. - /// - /// The type of items in the list. - /// The type of the projected result. - /// The source to project on each change. - /// A function projecting the current list snapshot to a result value. - /// An observable emitting the projected value after each changeset. - /// or is . - /// - /// Delegates to and applies via Select. - /// - /// - /// - /// - public static IObservable QueryWhenChanged(this IObservable> source, Func, TDestination> resultSelector) - where TObject : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - resultSelector.ThrowArgumentNullExceptionIfNull(nameof(resultSelector)); - - return source.QueryWhenChanged().Select(resultSelector); - } - - /// - /// Emits an snapshot of the current list state after every changeset. - /// Maintains an internal list updated by cloning each changeset. - /// - /// The type of items in the list. - /// The source to project on each change. - /// An observable emitting the full list snapshot as after each change. - /// is . - /// - /// This is a non-changeset operator. It emits the entire collection state on each change, not incremental diffs. - /// Worth noting: A new snapshot is emitted on every changeset, which can be chatty. The collection is rebuilt by cloning each changeset into an internal list. For sorted output, use . - /// - /// - /// - /// - public static IObservable> QueryWhenChanged(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new QueryWhenChanged(source).Run(); - } - - /// - /// Reference-counted materialization of the source changeset stream into an . - /// The shared list is created on the first subscriber and disposed when the last subscriber unsubscribes. - /// - /// The type of the item. - /// The source to share via reference counting. - /// A list changeset stream backed by a shared, reference-counted . - /// is . - /// - /// Equivalent to Publish().RefCount() for changeset streams. The underlying list is created lazily on first subscription. - /// - /// - public static IObservable> RefCount(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return new RefCount(source).Run(); - } - - /// - /// Strips index information from all changes in the stream. - /// - /// The type of the object. - /// The source to strip index information. - /// A list changeset stream with all index values removed from changes. - /// is . - /// - /// Removes index positions from every change in each changeset. This is useful when downstream operators do not require or support index-based operations. - /// - /// - public static IObservable> RemoveIndex(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select(changes => new ChangeSet(changes.YieldWithoutIndex())); - } - - /// - /// Reverses the order of items in the changeset stream by transforming all indices: new_index = length - old_index - 1. - /// - /// The type of the item. - /// The source to reverse. - /// A list changeset stream with all index positions reversed. - /// is . - /// - /// This is a pure index transformation. The items themselves are unchanged; only their positional indices are inverted. - /// - /// - public static IObservable> Reverse(this IObservable> source) - where T : notnull - { - var reverser = new Reverser(); - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.Select(changes => new ChangeSet(reverser.Reverse(changes))); - } - - /// - /// Skips the initial changeset (the snapshot emitted on subscription) and forwards all subsequent changesets. - /// Internally defers until loaded, then skips the first emission. - /// - /// The type of the object. - /// The source to skip the initial changeset. - /// A list changeset stream that omits the initial snapshot. - /// is . - /// - /// - /// Warning: This operator assumes the initial changeset is empty. If the source emits a non-empty - /// initial snapshot, those items are silently dropped while downstream consumers remain unaware of them. - /// Any later Refresh, Replace, Remove, or Moved change targeting one of those - /// dropped items will throw because the downstream collection has no record of them. Only use this against - /// a source you know starts empty (for example, a that has not yet been populated). - /// - /// - /// - /// - public static IObservable> SkipInitial(this IObservable> source) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.DeferUntilLoaded().Skip(1); - } - - /// - /// Sorts the list using the specified comparer, maintaining a sorted output that incrementally updates as items change. - /// - /// The type of the item. - /// The source to sort. - /// The used for sorting. - /// The for improved performance when sorted values are immutable. - /// An optional of that forces a full re-sort when it fires. Required when sorted property values are mutable. - /// An optional of that replaces the comparer, triggering a full re-sort. - /// When the number of changes exceeds this threshold, a full reset is performed instead of incremental updates. Default is 50. - /// A list changeset stream with items in sorted order. - /// or is . - /// - /// - /// Maintains an internal sorted list. Each incoming change is applied incrementally: adds are inserted at the correct sorted position, - /// removes are removed by index, and refreshes re-evaluate position (emitting Moved if changed). - /// - /// - /// EventBehavior - /// Add/AddRangeInserted at the correct sorted position. May trigger a full reset if the count exceeds . - /// ReplaceOld item removed, new item inserted at sorted position. - /// Remove/RemoveRange/ClearRemoved from sorted list. - /// RefreshSort position re-evaluated. If position changed, a Moved is emitted. - /// Comparer changedFull re-sort of all items. - /// Re-sort signalFull re-sort using the current comparer. - /// - /// Worth noting: is faster but requires that the values being sorted on never mutate. If they do, use the signal or . - /// - /// - /// - /// - /// - public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptions options = SortOptions.None, IObservable? resort = null, IObservable>? comparerChanged = null, int resetThreshold = 50) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); - - return new Sort(source, comparer, options, resort, comparerChanged, resetThreshold).Run(); - } - - /// - /// - /// Sorts the list using an observable comparer. The initial comparer is taken from the first emission; subsequent emissions trigger a full re-sort. - /// - /// - /// Until emits its first comparer, items are sorted using . Downstream still receives changesets immediately; the initial ordering is whatever produces, then a full re-sort happens once the first comparer arrives. - /// - /// The source to sort. - /// An of that emits comparers. The first emission provides the initial sort order; subsequent emissions trigger re-sorts. - /// for controlling sort behavior. - /// An optional of to force a re-sort with the current comparer. - /// The threshold for triggering a full reset instead of incremental updates. - public static IObservable> Sort(this IObservable> source, IObservable> comparerChanged, SortOptions options = SortOptions.None, IObservable? resort = null, int resetThreshold = 50) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparerChanged.ThrowArgumentNullExceptionIfNull(nameof(comparerChanged)); - - return new Sort(source, null, options, resort, comparerChanged, resetThreshold).Run(); - } - - /// - /// Prepends an empty changeset to the source stream. Useful for initializing downstream consumers that expect an initial emission. - /// - /// The type of item. - /// The source to prepend an empty changeset to. - /// A list changeset stream that begins with an empty changeset. - /// - /// - /// - public static IObservable> StartWithEmpty(this IObservable> source) - where T : notnull => source.StartWith(ChangeSet.Empty); - - /// - /// Creates an subscription for each item via when it is added. - /// The subscription is disposed when the item is removed or replaced. All subscriptions are disposed when the stream terminates. - /// The changeset is forwarded downstream unmodified. - /// - /// The type of the object. - /// The source to create a subscription for each item in. - /// A function that creates an for each item. - /// A continuation of the source changeset stream with per-item subscriptions managed as a side effect. - /// or is . - /// - /// - /// EventBehavior - /// Add/AddRangeSubscription created for each item via the factory. Changeset forwarded. - /// ReplaceOld item's subscription disposed, new subscription created. Changeset forwarded. - /// Remove/RemoveRange/ClearSubscriptions for removed items are disposed. Changeset forwarded. - /// Moved/RefreshForwarded. No subscription changes. - /// OnError/OnCompleted/DisposalAll active subscriptions are disposed. - /// - /// - /// - /// - /// - /// - public static IObservable> SubscribeMany(this IObservable> source, Func subscriptionFactory) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - subscriptionFactory.ThrowArgumentNullExceptionIfNull(nameof(subscriptionFactory)); - - return new SubscribeMany(source, subscriptionFactory).Run(); - } - - /// - /// Suppresses all changes from the stream. All other change reasons pass through. - /// - /// The type of the object. - /// The source to strip refresh events. - /// A list changeset stream with Refresh changes removed. - /// - /// - public static IObservable> SuppressRefresh(this IObservable> source) - where T : notnull => source.WhereReasonsAreNot(ListChangeReason.Refresh); - - /// - /// Subscribes to the latest inner , switching to each new source and clearing the result when switching. - /// This is the changeset-aware equivalent of Rx's , which cannot be applied directly to changeset streams. - /// - /// The type of the object. - /// An observable that emits instances. Each emission triggers a switch to the new list. - /// A list changeset stream reflecting the most recently received inner list. - /// is . - /// - /// Convenience overload that calls Connect() on each inner list, then delegates to . - /// - /// - public static IObservable> Switch(this IObservable> sources) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return sources.Select(cache => cache.Connect()).Switch(); - } - - /// - /// Subscribes to the latest inner changeset stream, switching to each new source and clearing the destination when switching. - /// Previous subscriptions are disposed and the result set is emptied before subscribing to the new inner stream. - /// - /// The type of the object. - /// An of changeset streams. The operator subscribes to the latest inner stream. - /// A list changeset stream reflecting the most recently received inner changeset stream. - /// is . - /// - /// - /// On each new inner stream, the operator clears the destination, disposes the previous subscription, and subscribes to the new stream. - /// This is the changeset-aware equivalent of Rx's Switch(). - /// - /// - /// - public static IObservable> Switch(this IObservable>> sources) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new Switch(sources).Run(); - } - - /// - /// Emits the full collection as an after every changeset. Equivalent to QueryWhenChanged(items => items). - /// - /// The type of items in the list. - /// The source to materialize into a collection on each change. - /// An observable emitting the full collection snapshot after each change. - /// - /// - /// - public static IObservable> ToCollection(this IObservable> source) - where TObject : notnull => source.QueryWhenChanged(items => items); - - /// - /// Bridges an into the DynamicData world by converting each emitted item into a list changeset. - /// Each emission becomes an Add operation in the resulting changeset stream. - /// - /// The type of the object. - /// The source to convert into a changeset stream. - /// An optional for time-based operations (expiry, size limiting). - /// A list changeset stream where each source emission is an Add. - /// is . - /// - /// - /// This is the primary bridge from standard Rx into DynamicData's list changeset model. Each item emitted by - /// is added to an internal list and an Add changeset is emitted. The list grows unboundedly unless size or time limits - /// are specified via other overloads. - /// - /// Worth noting: Source completion and errors are propagated. The internal list is disposed on unsubscribe. - /// - /// - /// - public static IObservable> ToObservableChangeSet( - this IObservable source, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an into a list changeset stream with per-item time-based expiry. - /// Expired items are automatically removed. - /// - /// The source to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// An optional for expiry timers. - public static IObservable> ToObservableChangeSet( - this IObservable source, - Func expireAfter, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an into a list changeset stream with FIFO size limiting. - /// When the list exceeds , the oldest items are removed. - /// - /// The source to convert into a changeset stream. - /// The maximum list size. Supply -1 to disable size limiting. - /// An optional for scheduling removals. - public static IObservable> ToObservableChangeSet( - this IObservable source, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// - /// Bridges an into a list changeset stream with both time-based expiry and FIFO size limiting. - /// - /// The source to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// The maximum list size. Supply -1 to disable size limiting. - /// An optional for expiry timers and size-limit checks. - public static IObservable> ToObservableChangeSet( - this IObservable source, - Func? expireAfter, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream. - /// Each emitted batch becomes an AddRange. - /// - /// The source of to convert into a changeset stream. - /// An optional for time-based operations. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream with FIFO size limiting. - /// - /// The source of to convert into a changeset stream. - /// The maximum list size. Oldest items are removed when the limit is exceeded. - /// An optional for scheduling removals. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: null, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream with time-based expiry. - /// - /// The source of to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// An optional for expiry timers. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - Func expireAfter, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: -1, - scheduler: scheduler); - - /// - /// - /// Bridges an of batches into a list changeset stream with both time-based expiry and FIFO size limiting. - /// - /// The source of to convert into a changeset stream. - /// A function returning the time-to-live for each item. Return for non-expiring items. - /// The maximum list size. Oldest items removed when exceeded. - /// An optional for expiry timers and size-limit checks. - public static IObservable> ToObservableChangeSet( - this IObservable> source, - Func? expireAfter, - int limitSizeTo, - IScheduler? scheduler = null) - where T : notnull - => List.Internal.ToObservableChangeSet.Create( - source: source, - expireAfter: expireAfter, - limitSizeTo: limitSizeTo, - scheduler: scheduler); - - /// - /// Takes the first items from the source list. Implemented as Virtualise with a fixed window starting at index 0. - /// - /// The type of the item. - /// The source to take the top items. - /// The maximum number of items to include. Must be greater than zero. - /// A virtual changeset stream containing at most items from the beginning of the source. - /// is . - /// is zero or negative. - /// - /// The source should ideally be sorted before applying Top, since list order determines which items appear. - /// - /// - /// - /// - public static IObservable> Top(this IObservable> source, int numberOfItems) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - if (numberOfItems <= 0) - { - throw new ArgumentOutOfRangeException(nameof(numberOfItems), "Number of items should be greater than zero"); - } - - return source.Virtualise(Observable.Return(new VirtualRequest(0, numberOfItems))); - } - - /// - /// Emits a sorted after every changeset, sorted by the value returned by . - /// - /// The type of items in the list. - /// The type of the sort key. - /// The source to materialize into a sorted collection on each change. - /// A function extracting the sort key from each item. - /// The sort direction. Defaults to ascending. - /// An observable emitting a sorted collection snapshot after each change. - /// - /// - /// - /// - public static IObservable> ToSortedCollection(this IObservable> source, Func sort, SortDirection sortOrder = SortDirection.Ascending) - where TObject : notnull => source.QueryWhenChanged(query => sortOrder == SortDirection.Ascending ? new ReadOnlyCollectionLight(query.OrderBy(sort)) : new ReadOnlyCollectionLight(query.OrderByDescending(sort))); - - /// - /// Emits a sorted after every changeset, sorted using the specified . - /// - /// The type of items in the list. - /// The source to materialize into a sorted collection on each change. - /// The used for sorting. - /// An observable emitting a sorted collection snapshot after each change. - /// - /// - public static IObservable> ToSortedCollection(this IObservable> source, IComparer comparer) - where TObject : notnull => source.QueryWhenChanged( - query => - { - var items = query.AsList(); - items.Sort(comparer); - return new ReadOnlyCollectionLight(items); - }); - - /// - /// Projects each item to a new form using a synchronous transform function. - /// - /// The type of the source items. - /// The type of the destination items. - /// The source to transform. - /// The transform function applied to each item. - /// When , Refresh events re-invoke the factory and emit an update. When (the default), Refresh is forwarded without re-transforming. - /// A list changeset stream of transformed items. - /// - /// - /// Maintains an internal list of transformed items. Each source changeset is - /// processed and a corresponding output changeset is produced with the transformed items. - /// - /// - /// EventBehavior - /// AddThe factory is called and an Add is emitted at the same index. - /// AddRangeThe factory is called for each item. An AddRange is emitted at the same start index. - /// ReplaceThe factory is called for the new item. A Replace is emitted at the same index. The previous transformed value is available to overloads that accept . - /// RemoveA Remove is emitted (no factory call). - /// RemoveRangeA RemoveRange is emitted. - /// MovedA Moved is emitted with updated indices (no factory call). Throws if the source change has no index information. - /// RefreshIf is (default), the Refresh is forwarded without re-transforming. If , the factory is re-invoked and the result replaces the current value. - /// ClearA Clear is emitted and the internal list is emptied. - /// OnErrorIf the factory throws, the exception propagates as OnError. - /// - /// Worth noting: By default, Refresh does NOT re-transform the item (it just forwards the signal). Set to if you need the factory re-invoked on Refresh. Add operations with out-of-bounds indices silently append to the end. - /// - /// or is . - /// - /// - /// - /// - public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((t, _, _) => transformFactory(t), transformOnRefresh); - } - - /// - /// - /// Projects each item using a transform function that also receives the item's index. - /// - public static IObservable> Transform(this IObservable> source, Func transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((t, _, idx) => transformFactory(t, idx), transformOnRefresh); - } - - /// - /// - /// Projects each item using a transform function that also receives the previously transformed value (if any). - /// Type arguments must be specified explicitly as type inference fails for this overload. - /// - public static IObservable> Transform(this IObservable> source, Func, TDestination> transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.Transform((t, previous, _) => transformFactory(t, previous), transformOnRefresh); - } - - /// - /// - /// Projects each item using a transform function that receives the source item, the previously transformed value, and the index. - /// Type arguments must be specified explicitly as type inference fails for this overload. - /// - public static IObservable> Transform(this IObservable> source, Func, int, TDestination> transformFactory, bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new Transformer(source, transformFactory, transformOnRefresh).Run(); - } - - /// - /// Projects each item to a new form using an async transform function. Behaves like but the factory returns a . - /// - /// The type of the source items. - /// The type of the destination items. - /// The source to transform asynchronously. - /// An async function that transforms each source item. - /// When , Refresh events re-invoke the factory. - /// A list changeset stream of asynchronously transformed items. - /// or is . - /// - /// Change handling is identical to the synchronous except the factory is awaited. Operations are serialized per changeset via a semaphore. - /// - /// EventBehavior - /// Add/AddRangeThe async factory is awaited for each item. An Add/AddRange is emitted with the transformed results. - /// ReplaceThe async factory is awaited for the new item. A Replace is emitted. - /// Remove/RemoveRangeEmitted without invoking the factory. - /// MovedEmitted with updated indices (no factory call). - /// RefreshIf is (default), forwarded without re-transforming. If , the factory is re-awaited. - /// ClearEmitted and internal list cleared. - /// OnErrorIf the async factory throws, the exception propagates as OnError. - /// OnCompletedForwarded after the last changeset is processed. - /// - /// Worth noting: All async transforms within a single changeset are serialized (not parallel). Each changeset is fully processed before the next begins. By default, Refresh does NOT re-transform. - /// - /// - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((t, _, _) => transformFactory(t), transformOnRefresh); - } - - /// - /// - /// Async transform overload receiving the source item and its index. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((t, _, i) => transformFactory(t, i), transformOnRefresh); - } - - /// - /// - /// Async transform overload receiving the source item and the previously transformed value. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func, Task> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return source.TransformAsync((t, d, _) => transformFactory(t, d), transformOnRefresh); - } - - /// - /// - /// Async transform overload receiving the source item, previously transformed value, and index. This is the terminal overload that all other TransformAsync overloads delegate to. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'.", Justification = "By Design.")] - public static IObservable> TransformAsync( - this IObservable> source, - Func, int, Task> transformFactory, - bool transformOnRefresh = false) - where TSource : notnull - where TDestination : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - transformFactory.ThrowArgumentNullExceptionIfNull(nameof(transformFactory)); - - return new TransformAsync(source, transformFactory, transformOnRefresh).Run(); - } - - /// - /// Flattens each source item into multiple destination items using . Each source item produces zero or more children, - /// all of which are merged into a single flat list changeset stream. - /// - /// The type of the destination items. - /// The type of the source items. - /// The source to expand each item into multiple children. - /// A function that returns the child items for each source item. - /// An optional used during Replace to determine which child items changed between old and new parent values. - /// A list changeset stream of all child items from all source items. - /// or is . - /// - /// - /// EventBehavior - /// Add/AddRangeChildren expanded and added to the output. - /// ReplaceOld children diffed against new children (using ). Removed, added, or kept as appropriate. - /// Remove/RemoveRange/ClearAll children of the removed parents are removed from the output. - /// RefreshChildren re-expanded and diffed. - /// - /// - /// - /// - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - manySelector.ThrowArgumentNullExceptionIfNull(nameof(manySelector)); - - return new TransformMany(source, manySelector, equalityComparer).Run(); - } - - /// - /// - /// Flattens each source item into children from an . The collection is observed for subsequent changes. - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); - - /// - /// - /// Flattens each source item into children from a . The collection is observed for subsequent changes. - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); - - /// - /// - /// Flattens each source item into children from an . The inner list is observed for subsequent changes. - /// - public static IObservable> TransformMany(this IObservable> source, Func> manySelector, IEqualityComparer? equalityComparer = null) - where TDestination : notnull - where TSource : notnull => new TransformMany(source, manySelector, equalityComparer).Run(); - - /// - /// Applies a sliding window to the source list using start index and size from . - /// Only items within the window are included downstream. - /// - /// The type of the item. - /// The source to virtualize. - /// An observable of specifying the start index and size of the window. - /// An stream containing only items within the current virtual window. - /// or is . - /// - /// - /// Like but uses absolute start index and size instead of page number and page size. - /// Internally maintains the full source list and recalculates the window on each change or request. - /// - /// - /// - /// - public static IObservable> Virtualise(this IObservable> source, IObservable requests) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - requests.ThrowArgumentNullExceptionIfNull(nameof(requests)); - - return new Virtualiser(source, requests).Run(); - } - - /// - /// Watches all items in the source list and emits the item when any of its properties change. - /// Requires to implement . - /// This is NOT a changeset operator: it returns a flat . - /// - /// The type of the object. Must implement . - /// The source to observe property changes on items in. - /// An optional list of property names to monitor. If empty, all property changes are observed. - /// An observable emitting the item whenever any monitored property changes. - /// is . - /// - /// Implemented via . Subscriptions are managed per item: created on add, disposed on remove. - /// - /// - /// - /// - /// - public static IObservable WhenAnyPropertyChanged(this IObservable> source, params string[] propertiesToMonitor) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - return source.MergeMany(t => t.WhenAnyPropertyChanged(propertiesToMonitor)); - } - - /// - /// Watches a specific property on all items in the source list and emits a (item + value pair) when it changes. - /// Requires to implement . - /// This is NOT a changeset operator: it returns a flat . - /// - /// The type of item. Must implement . - /// The type of the property value. - /// The source to observe a specific property on items in. - /// An expression selecting the property to observe. - /// When (default), the current value is emitted immediately upon subscribing to each item. - /// An observable emitting whenever the property changes on any tracked item. - /// or is . - /// - /// Implemented via . - /// - /// - /// - /// - public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - var factory = propertyAccessor.GetFactory(); - return source.MergeMany(t => factory(t, notifyOnInitialValue)); - } - - /// - /// Watches a specific property on all items and emits just the property value (without the sender) when it changes. - /// Requires to implement . - /// This is NOT a changeset operator: it returns a flat . - /// - /// The type of item. Must implement . - /// The type of the property value. - /// The source to observe a specific property value on items in. - /// An expression selecting the property to observe. - /// When (default), the current value is emitted immediately upon subscribing to each item. - /// An observable emitting the property value whenever it changes on any tracked item. - /// or is . - /// - /// - /// - public static IObservable WhenValueChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) - where TObject : INotifyPropertyChanged - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - propertyAccessor.ThrowArgumentNullExceptionIfNull(nameof(propertyAccessor)); - - var factory = propertyAccessor.GetFactory(); - return source.MergeMany(t => factory(t, notifyOnInitialValue).Select(pv => pv.Value)); - } - - /// - /// Filters the changeset stream to include only changes with the specified values. - /// Index information is stripped from the output because removing some changes invalidates the original index positions. - /// - /// The type of the item. - /// The source to filter by change reason. - /// The change reasons to include. Must specify at least one. - /// A list changeset stream containing only changes with the specified reasons. - /// is . - /// is empty. - /// - /// Filters individual changes within each changeset. If filtering removes all changes from a changeset, the empty changeset is suppressed via . - /// Worth noting: Filtering out Remove changes can cause downstream operators to accumulate items indefinitely (memory leak). Index information is stripped because removing some changes invalidates the original index positions. - /// - /// - /// - /// - public static IObservable> WhereReasonsAre(this IObservable> source, params ListChangeReason[] reasons) - where T : notnull - { - reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); - - if (reasons.Length == 0) - { - throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); - } - - var matches = new HashSet(reasons); - return source.Select( - changes => - { - var filtered = changes.Where(change => matches.Contains(change.Reason)).YieldWithoutIndex(); - return new ChangeSet(filtered); - }).NotEmpty(); - } - - /// - /// Filters the changeset stream to exclude changes with the specified values. - /// Index information is stripped from the output because removing some changes invalidates the original index positions. - /// The exception is when only is excluded, since removing Refresh does not affect index calculations. - /// - /// The type of the item. - /// The source to filter by excluding change reasons. - /// The change reasons to exclude. Must specify at least one. - /// A list changeset stream with the specified change reasons removed. - /// is . - /// is empty. - /// - /// - /// Empty changesets (after filtering) are automatically suppressed. When only is excluded, - /// indices are preserved, since removing Refresh does not affect index calculations. - /// - /// - /// - /// - /// - public static IObservable> WhereReasonsAreNot(this IObservable> source, params ListChangeReason[] reasons) - where T : notnull - { - reasons.ThrowArgumentNullExceptionIfNull(nameof(reasons)); - - if (reasons.Length == 0) - { - throw new ArgumentException("Must enter at least 1 reason", nameof(reasons)); - } - - if (reasons.Length == 1 && reasons[0] == ListChangeReason.Refresh) - { - // If only refresh changes are removed, then there's no need to remove the indexes - return source.Select(changes => - { - var filtered = changes.Where(c => c.Reason != ListChangeReason.Refresh); - return new ChangeSet(filtered); - }).NotEmpty(); - } - - var matches = new HashSet(reasons); - return source.Select( - updates => - { - var filtered = updates.Where(u => !matches.Contains(u.Reason)).YieldWithoutIndex(); - return new ChangeSet(filtered); - }).NotEmpty(); - } - - /// - /// Applies a logical XOR (symmetric difference) between the source and other streams. - /// Items present in exactly one source are included in the result. - /// - /// The type of the item. - /// The primary source to exclusively combine. - /// The other changeset streams to combine with. - /// A list changeset stream containing items that exist in exactly one source. - /// is . - /// - /// - /// Item identity is determined by the default equality comparer for . Uses reference-counted equality: an item is included when it exists in exactly one source. - /// If it appears in a second source, it is removed from the result. If it then leaves one source, - /// it re-enters the result. Moved changes are ignored. - /// - /// - /// EventBehavior - /// Add/AddRangeReference count updated. If the item is now in exactly one source, an Add is emitted. If now in two or more, a Remove is emitted. - /// Remove/RemoveRange/ClearReference count decremented. If now in exactly one source, an Add is emitted. If now in zero, a Remove is emitted. - /// ReplaceOld item reference count decremented, new item incremented, with Xor logic applied. - /// RefreshForwarded if item is in the result set. - /// MovedIgnored. - /// - /// - /// - /// - /// - /// - public static IObservable> Xor(this IObservable> source, params IObservable>[] others) - where T : notnull - { - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - return source.Combine(CombineOperator.Xor, others); - } - - /// - /// - /// Applies a logical XOR between a pre-built collection of list changeset sources. - /// - public static IObservable> Xor(this ICollection>> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - /// - /// - /// Dynamic XOR: sources can be added or removed from the at runtime. - /// - public static IObservable> Xor(this IObservableList>> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - /// - /// - /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Xor(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - /// - /// - /// Dynamic XOR accepting of . Each inner list's Connect() is used as a source. - /// - public static IObservable> Xor(this IObservableList> sources) - where T : notnull => sources.Combine(CombineOperator.Xor); - - private static IObservable> Combine(this ICollection>> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new Combiner(sources, type).Run(); - } - - private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] others) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - if (others.Length == 0) - { - throw new ArgumentException("Must be at least one item to combine with", nameof(others)); - } - - var items = source.EnumerateOne().Union(others).ToList(); - return new Combiner(items, type).Run(); - } - - private static IObservable> Combine(this IObservableList> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); - var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(changesSetList, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); - var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(changesSetList, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList>> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new DynamicCombiner(sources, type).Run(); - } -} From 572ddba95d1d9856df7fc96fa35ec3109c133aa6 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Tue, 26 May 2026 07:33:55 -0700 Subject: [PATCH 2/2] Rename Pagination to Virtualise and alphabetize list partial members Renames ObservableListEx.Pagination.cs to ObservableListEx.Virtualise.cs for closer parity with the cache equivalent (ObservableCacheEx.VirtualiseAndPage.cs). Sorts members alphabetically within each new partial file; overloads of the same name preserve their original declaration order. --- .../List/ObservableListEx.Combinators.cs | 118 +++++++++--------- .../List/ObservableListEx.Merge.cs | 68 +++++----- ...tion.cs => ObservableListEx.Virtualise.cs} | 2 +- 3 files changed, 94 insertions(+), 94 deletions(-) rename src/DynamicData/List/{ObservableListEx.Pagination.cs => ObservableListEx.Virtualise.cs} (98%) diff --git a/src/DynamicData/List/ObservableListEx.Combinators.cs b/src/DynamicData/List/ObservableListEx.Combinators.cs index dadc6a53..77298688 100644 --- a/src/DynamicData/List/ObservableListEx.Combinators.cs +++ b/src/DynamicData/List/ObservableListEx.Combinators.cs @@ -95,6 +95,65 @@ public static IObservable> And(this IObservableList> And(this IObservableList> sources) where T : notnull => sources.Combine(CombineOperator.And); + private static IObservable> Combine(this ICollection>> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new Combiner(sources, type).Run(); + } + + private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] others) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + others.ThrowArgumentNullExceptionIfNull(nameof(others)); + + if (others.Length == 0) + { + throw new ArgumentException("Must be at least one item to combine with", nameof(others)); + } + + var items = source.EnumerateOne().Union(others).ToList(); + return new Combiner(items, type).Run(); + } + + private static IObservable> Combine(this IObservableList> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); + var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(changesSetList, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return Observable.Create>( + observer => + { + var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); + var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); + return new CompositeDisposable(changesSetList, subscriber); + }); + } + + private static IObservable> Combine(this IObservableList>> sources, CombineOperator type) + where T : notnull + { + sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); + + return new DynamicCombiner(sources, type).Run(); + } + /// /// Applies a logical set-difference (Except) between the source and other streams. /// Items present in the first source but not in any of the are included in the result. @@ -293,63 +352,4 @@ public static IObservable> Xor(this IObservableList public static IObservable> Xor(this IObservableList> sources) where T : notnull => sources.Combine(CombineOperator.Xor); - - private static IObservable> Combine(this ICollection>> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new Combiner(sources, type).Run(); - } - - private static IObservable> Combine(this IObservable> source, CombineOperator type, params IObservable>[] others) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - others.ThrowArgumentNullExceptionIfNull(nameof(others)); - - if (others.Length == 0) - { - throw new ArgumentException("Must be at least one item to combine with", nameof(others)); - } - - var items = source.EnumerateOne().Union(others).ToList(); - return new Combiner(items, type).Run(); - } - - private static IObservable> Combine(this IObservableList> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); - var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(changesSetList, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return Observable.Create>( - observer => - { - var changesSetList = sources.Connect().Transform(s => s.Connect()).AsObservableList(); - var subscriber = changesSetList.Combine(type).SubscribeSafe(observer); - return new CompositeDisposable(changesSetList, subscriber); - }); - } - - private static IObservable> Combine(this IObservableList>> sources, CombineOperator type) - where T : notnull - { - sources.ThrowArgumentNullExceptionIfNull(nameof(sources)); - - return new DynamicCombiner(sources, type).Run(); - } } diff --git a/src/DynamicData/List/ObservableListEx.Merge.cs b/src/DynamicData/List/ObservableListEx.Merge.cs index d31b570d..78613551 100644 --- a/src/DynamicData/List/ObservableListEx.Merge.cs +++ b/src/DynamicData/List/ObservableListEx.Merge.cs @@ -23,40 +23,6 @@ namespace DynamicData; /// public static partial class ObservableListEx { - /// - /// Subscribes to a per-item observable for each item in the source and merges all emissions into a single stream. - /// This is NOT a changeset operator: it returns a flat observable of values. - /// - /// The type of items in the source list. - /// The type of values emitted by per-item observables. - /// The source whose items each produce an observable. - /// A function that returns an observable for each source item. - /// An observable that emits values from all per-item observables, merged together. - /// or is . - /// - /// - /// Event (source)Subscription behavior - /// Add/AddRangeSubscribes to the per-item observable. Emissions are merged into the output. - /// ReplaceOld subscription disposed, new subscription created for the replacement item. - /// Remove/RemoveRange/ClearSubscription disposed. - /// Refresh/MovedNo effect on subscriptions. - /// OnCompleted (source)Completes only after the source and all active inner observables have completed. - /// - /// - /// - /// - /// - /// - public static IObservable MergeMany(this IObservable> source, Func> observableSelector) - where T : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - - return new MergeMany(source, observableSelector).Run(); - } - /// /// /// Merges multiple list changeset streams from an observable-of-observables into a single unified changeset stream. @@ -249,6 +215,40 @@ public static IObservable> MergeChangeSets src, equalityComparer, comparer); } + /// + /// Subscribes to a per-item observable for each item in the source and merges all emissions into a single stream. + /// This is NOT a changeset operator: it returns a flat observable of values. + /// + /// The type of items in the source list. + /// The type of values emitted by per-item observables. + /// The source whose items each produce an observable. + /// A function that returns an observable for each source item. + /// An observable that emits values from all per-item observables, merged together. + /// or is . + /// + /// + /// Event (source)Subscription behavior + /// Add/AddRangeSubscribes to the per-item observable. Emissions are merged into the output. + /// ReplaceOld subscription disposed, new subscription created for the replacement item. + /// Remove/RemoveRange/ClearSubscription disposed. + /// Refresh/MovedNo effect on subscriptions. + /// OnCompleted (source)Completes only after the source and all active inner observables have completed. + /// + /// + /// + /// + /// + /// + public static IObservable MergeMany(this IObservable> source, Func> observableSelector) + where T : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); + + return new MergeMany(source, observableSelector).Run(); + } + /// /// Transforms each source item into a child list changeset stream using , /// then merges all child streams into a single flat list changeset stream. Parent item removal cleans up all associated children. diff --git a/src/DynamicData/List/ObservableListEx.Pagination.cs b/src/DynamicData/List/ObservableListEx.Virtualise.cs similarity index 98% rename from src/DynamicData/List/ObservableListEx.Pagination.cs rename to src/DynamicData/List/ObservableListEx.Virtualise.cs index e1106cbe..501c8bbc 100644 --- a/src/DynamicData/List/ObservableListEx.Pagination.cs +++ b/src/DynamicData/List/ObservableListEx.Virtualise.cs @@ -19,7 +19,7 @@ namespace DynamicData; /// -/// ObservableList extensions for Page and Virtualise. +/// ObservableList extensions for Virtualise and Page. /// public static partial class ObservableListEx {