Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SortAndBind fixes and improvements #939

Merged
merged 7 commits into from
Jan 20, 2025

Large diffs are not rendered by default.

49 changes: 48 additions & 1 deletion src/DynamicData.Tests/Cache/SortAndBindFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ protected override (ChangeSetAggregator<Person, string> Aggregrator, IList<Perso
}


// Bind to a binding list
public sealed class SortAndBindToBindingList : SortAndBindFixture

{
protected override (ChangeSetAggregator<Person, string> Aggregrator, IList<Person> List) SetUpTests()
{
var list = new ObservableCollection<Person>(new BindingList<Person>());
var aggregator = _source.Connect().SortAndBind(list, _comparer).AsAggregator();
return (aggregator, list);
}
}


// Bind to a readonly observable collection
public sealed class SortAndBindToReadOnlyObservableCollection: SortAndBindFixture
{
Expand Down Expand Up @@ -151,7 +164,7 @@ public void NeverFireReset()
using var sorted = _source.Connect().SortAndBind(out var list, _comparer, options).Subscribe();
using var collectionChangedEvents = list.ObserveCollectionChanges().Select(e => e.EventArgs).Subscribe(_collectionChangedEventArgs.Add);

// fire 5 changes, should always reset because it's below the threshold
// fire 5 changes, should not reset because it's below the threshold
_source.AddOrUpdate(Enumerable.Range(0, 5).Select(i => new Person($"P{i}", i)));
_collectionChangedEventArgs.Count.Should().Be(5);
_collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Add).Should().BeTrue();
Expand All @@ -168,6 +181,40 @@ public void NeverFireReset()

}

[Fact]
[Description("Check reset is fired on first time load. This checks historic first time load opt-in.")]
public void FireResetOnFirstTimeLoad()
{
var options = new SortAndBindOptions { ResetThreshold = 10, ResetOnFirstTimeLoad = true};

using var sorted = _source.Connect().SortAndBind(out var list, _comparer, options).Subscribe();
using var collectionChangedEvents = list.ObserveCollectionChanges().Select(e => e.EventArgs).Subscribe(_collectionChangedEventArgs.Add);

// fire 5 changes, should always reset even though it's below the threshold
_source.AddOrUpdate(Enumerable.Range(0, 5).Select(i => new Person($"P{i}", i)));
_collectionChangedEventArgs.Count.Should().Be(1);
_collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Reset).Should().BeTrue();


_collectionChangedEventArgs.Clear();

// fire 15 changes, we should get a refresh event
_source.AddOrUpdate(Enumerable.Range(10, 15).Select(i => new Person($"P{i}", i)));
_collectionChangedEventArgs.Count.Should().Be(1);
_collectionChangedEventArgs[0].Action.Should().Be(NotifyCollectionChangedAction.Reset);

_collectionChangedEventArgs.Clear();

// fires further 5 changes, should result individual notifications
_source.AddOrUpdate(Enumerable.Range(-10, 5).Select(i => new Person($"P{i}", i)));
_collectionChangedEventArgs.Count.Should().Be(5);
_collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Add).Should().BeTrue();

list.Count.Should().Be(25);

}



public void Dispose() => _source.Dispose();
}
Expand Down
8 changes: 5 additions & 3 deletions src/DynamicData/Binding/BindPaged.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Roland Pheasant licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
Expand All @@ -16,7 +17,8 @@ namespace DynamicData.Binding;
internal sealed class BindPaged<TObject, TKey>(
IObservable<IChangeSet<TObject, TKey, PageContext<TObject>>> source,
IList<TObject> targetList,
SortAndBindOptions? options)
SortAndBindOptions? options,
IScheduler? scheduler)
where TObject : notnull
where TKey : notnull
{
Expand All @@ -31,7 +33,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseProvidedOptions(SortAndBindOpt
.Select(changesWithContext => changesWithContext.Context.Comparer)
.DistinctUntilChanged();

return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions);
return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions, scheduler);
});

private IObservable<IChangeSet<TObject, TKey>> UseContextSortOptions() =>
Expand Down Expand Up @@ -68,7 +70,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseContextSortOptions() =>
};

subscriber.Disposable = changesSubject
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions)
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions, scheduler)
.SubscribeSafe(observer);

comparerSubject.OnNext(changesWithContext.Context.Comparer);
Expand Down
8 changes: 5 additions & 3 deletions src/DynamicData/Binding/BindVirtualized.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Roland Pheasant licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
Expand All @@ -14,7 +15,8 @@ namespace DynamicData.Binding;
internal sealed class BindVirtualized<TObject, TKey>(
IObservable<IChangeSet<TObject, TKey, VirtualContext<TObject>>> source,
IList<TObject> targetList,
SortAndBindOptions? options)
SortAndBindOptions? options,
IScheduler? scheduler)
where TObject : notnull
where TKey : notnull
{
Expand All @@ -29,7 +31,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseProvidedOptions(SortAndBindOpt
.Select(changesWithContext => changesWithContext.Context.Comparer)
.DistinctUntilChanged();

return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions);
return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions, scheduler);
});

private IObservable<IChangeSet<TObject, TKey>> UseVirtualSortOptions() =>
Expand Down Expand Up @@ -66,7 +68,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseVirtualSortOptions() =>
};

subscriber.Disposable = changesSubject
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions)
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions, scheduler)
.SubscribeSafe(observer);

comparerSubject.OnNext(changesWithContext.Context.Comparer);
Expand Down
34 changes: 15 additions & 19 deletions src/DynamicData/Binding/BindingListEventsSuspender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,26 @@
// Roland Pheasant licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

#if SUPPORTS_BINDINGLIST
using System.ComponentModel;
using System.Reactive.Disposables;

namespace DynamicData.Binding
{
internal sealed class BindingListEventsSuspender<T> : IDisposable
{
private readonly IDisposable _cleanUp;
namespace DynamicData.Binding;

public BindingListEventsSuspender(BindingList<T> list)
{
list.RaiseListChangedEvents = false;
internal sealed class BindingListEventsSuspender<T> : IDisposable
{
private readonly IDisposable _cleanUp;

_cleanUp = Disposable.Create(
() =>
{
list.RaiseListChangedEvents = true;
list.ResetBindings();
});
}
public BindingListEventsSuspender(BindingList<T> list)
{
list.RaiseListChangedEvents = false;

public void Dispose() => _cleanUp.Dispose();
_cleanUp = Disposable.Create(
() =>
{
list.RaiseListChangedEvents = true;
list.ResetBindings();
});
}
}

#endif
public void Dispose() => _cleanUp.Dispose();
}
51 changes: 42 additions & 9 deletions src/DynamicData/Binding/SortAndBind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// 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.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using DynamicData.Cache;
Expand All @@ -28,26 +30,43 @@ internal sealed class SortAndBind<TObject, TKey>
public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
IComparer<TObject> comparer,
SortAndBindOptions options,
IList<TObject> target)
IList<TObject> target,
IScheduler? scheduler)
{
scheduler ??= DynamicDataOptions.BindingScheduler;

// static one time comparer
var applicator = new SortApplicator(_cache, target, comparer, options);

_sorted = source.Do(changes =>
if (scheduler is not null)
source = source.ObserveOn(scheduler);

_sorted = source.Select((changes, index) =>
{
// clone to local cache so that we can sort the entire set when threshold is over a certain size.
_cache.Clone(changes);

applicator.ProcessChanges(changes);
applicator.ProcessChanges(changes, index == 0);

return changes;
});
}

public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
IObservable<IComparer<TObject>> comparerChanged,
SortAndBindOptions options,
IList<TObject> target)
IList<TObject> target,
IScheduler? scheduler)
=> _sorted = Observable.Create<IChangeSet<TObject, TKey>>(observer =>
{
scheduler ??= DynamicDataOptions.BindingScheduler;

if (scheduler is not null)
{
source = source.ObserveOn(scheduler);
comparerChanged = comparerChanged.ObserveOn(scheduler);
}

var locker = new object();
SortApplicator? sortApplicator = null;

Expand All @@ -61,14 +80,17 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,

// Listen to changes and apply the sorting
var subscriber = source.Synchronize(locker)
.Do(changes =>
.Select((changes, index) =>
{
_cache.Clone(changes);

// the sort applicator will be null until the comparer change observable fires.
if (sortApplicator is not null)
sortApplicator.ProcessChanges(changes);
}).SubscribeSafe(observer);
sortApplicator.ProcessChanges(changes, index == 0);

return changes;
})
.SubscribeSafe(observer);

return new CompositeDisposable(latestComparer, subscriber);
});
Expand All @@ -92,10 +114,12 @@ public void ApplySort()
}

// apply sorting as a side effect of the observable stream.
public void ProcessChanges(IChangeSet<TObject, TKey> changeSet)
public void ProcessChanges(IChangeSet<TObject, TKey> changeSet, bool isFirstTimeLoad)
{
var forceReset = isFirstTimeLoad && options.ResetOnFirstTimeLoad;

// apply sorted changes to the target collection
if (options.ResetThreshold > 0 && options.ResetThreshold < changeSet.Count)
if (forceReset || (options.ResetThreshold > 0 && options.ResetThreshold < changeSet.Count))
{
Reset(cache.Items.OrderBy(t => t, comparer), true);
}
Expand All @@ -122,6 +146,15 @@ private void Reset(IEnumerable<TObject> sorted, bool fireReset)
observableCollectionExtended.Load(sorted);
}
}
else if (fireReset && target is BindingList<TObject> bindingList)
{
// suspend count as it can result in a flood of binding updates.
using (new BindingListEventsSuspender<TObject>(bindingList))
{
target.Clear();
target.AddRange(sorted);
}
}
else
{
target.Clear();
Expand Down
8 changes: 8 additions & 0 deletions src/DynamicData/Binding/SortAndBindOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,12 @@ public record struct SortAndBindOptions()
/// Set the initial capacity of the readonly observable collection.
/// </summary>
public int InitialCapacity { get; init; } = -1;

/// <summary>
/// Reset on first time load.
///
/// This is opt-in only and is only required for consumers who need to maintain
/// backwards compatibility will the former BindingOptions.ResetOnFirstTimeLoad.
/// </summary>
public bool ResetOnFirstTimeLoad { get; init; }
}
Loading
Loading