diff --git a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt index 649895245..5ee7dd30e 100644 --- a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt +++ b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt @@ -1175,6 +1175,18 @@ namespace DynamicData public static System.IObservable> BatchIf(this System.IObservable> source, System.IObservable pauseIfTrueSelector, bool initialPauseState = false, System.TimeSpan? timeOut = default, System.Reactive.Concurrency.IScheduler? scheduler = null) where TObject : notnull where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, System.Collections.Generic.IList targetList) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, System.Collections.Generic.IList targetList) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection) + where TObject : notnull + where TKey : notnull { } public static System.IObservable> Bind(this System.IObservable> source, DynamicData.Binding.IObservableCollection destination) where TObject : notnull where TKey : notnull { } @@ -1193,6 +1205,18 @@ namespace DynamicData public static System.IObservable> Bind(this System.IObservable> source, System.ComponentModel.BindingList bindingList, int resetThreshold = 25) where TObject : notnull where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, System.Collections.Generic.IList targetList, DynamicData.Binding.SortAndBindOptions options) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection, DynamicData.Binding.SortAndBindOptions options) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, System.Collections.Generic.IList targetList, DynamicData.Binding.SortAndBindOptions options) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable> Bind(this System.IObservable>> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection, DynamicData.Binding.SortAndBindOptions options) + where TObject : notnull + where TKey : notnull { } public static System.IObservable> Bind(this System.IObservable> source, DynamicData.Binding.IObservableCollection destination, DynamicData.Binding.BindingOptions options) where TObject : notnull where TKey : notnull { } @@ -1662,6 +1686,7 @@ namespace DynamicData public static System.IObservable> Or(this System.IObservable> source, params System.IObservable>[] others) where TObject : notnull where TKey : notnull { } + [System.Obsolete("Use SortAndPage as it\'s more efficient")] public static System.IObservable> Page(this System.IObservable> source, System.IObservable pageRequests) where TObject : notnull where TKey : notnull { } @@ -1755,15 +1780,19 @@ namespace DynamicData public static System.IObservable> SkipInitial(this System.IObservable> source) where TObject : notnull where TKey : notnull { } + [System.Obsolete("Use SortAndBind as it\'s more efficient")] public static System.IObservable> Sort(this System.IObservable> source, System.Collections.Generic.IComparer comparer, DynamicData.SortOptimisations sortOptimisations = 0, int resetThreshold = 100) where TObject : notnull where TKey : notnull { } + [System.Obsolete("Use SortAndBind as it\'s more efficient")] public static System.IObservable> Sort(this System.IObservable> source, System.IObservable> comparerObservable, DynamicData.SortOptimisations sortOptimisations = 0, int resetThreshold = 100) where TObject : notnull where TKey : notnull { } + [System.Obsolete("Use SortAndBind as it\'s more efficient")] public static System.IObservable> Sort(this System.IObservable> source, System.Collections.Generic.IComparer comparer, System.IObservable resorter, DynamicData.SortOptimisations sortOptimisations = 0, int resetThreshold = 100) where TObject : notnull where TKey : notnull { } + [System.Obsolete("Use SortAndBind as it\'s more efficient")] public static System.IObservable> Sort(this System.IObservable> source, System.IObservable> comparerObservable, System.IObservable resorter, DynamicData.SortOptimisations sortOptimisations = 0, int resetThreshold = 100) where TObject : notnull where TKey : notnull { } @@ -1773,12 +1802,6 @@ namespace DynamicData public static System.IObservable> SortAndBind(this System.IObservable> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection) where TObject : notnull, System.IComparable where TKey : notnull { } - public static System.IObservable> SortAndBind(this System.IObservable>> source, System.Collections.Generic.IList targetList) - where TObject : notnull - where TKey : notnull { } - public static System.IObservable> SortAndBind(this System.IObservable>> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection) - where TObject : notnull - where TKey : notnull { } public static System.IObservable> SortAndBind(this System.IObservable> source, System.Collections.Generic.IList targetList, DynamicData.Binding.SortAndBindOptions options) where TObject : notnull, System.IComparable where TKey : notnull { } @@ -1797,12 +1820,6 @@ namespace DynamicData public static System.IObservable> SortAndBind(this System.IObservable> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection, System.IObservable> comparerChanged) where TObject : notnull where TKey : notnull { } - public static System.IObservable> SortAndBind(this System.IObservable>> source, System.Collections.Generic.IList targetList, DynamicData.Binding.SortAndBindOptions options) - where TObject : notnull - where TKey : notnull { } - public static System.IObservable> SortAndBind(this System.IObservable>> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection, DynamicData.Binding.SortAndBindOptions options) - where TObject : notnull - where TKey : notnull { } public static System.IObservable> SortAndBind(this System.IObservable> source, System.Collections.Generic.IList targetList, System.Collections.Generic.IComparer comparer, DynamicData.Binding.SortAndBindOptions options) where TObject : notnull where TKey : notnull { } @@ -1815,6 +1832,18 @@ namespace DynamicData public static System.IObservable> SortAndBind(this System.IObservable> source, out System.Collections.ObjectModel.ReadOnlyObservableCollection readOnlyObservableCollection, System.IObservable> comparerChanged, DynamicData.Binding.SortAndBindOptions options) where TObject : notnull where TKey : notnull { } + public static System.IObservable>> SortAndPage(this System.IObservable> source, System.Collections.Generic.IComparer comparer, System.IObservable pageRequests) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable>> SortAndPage(this System.IObservable> source, System.IObservable> comparerChanged, System.IObservable pageRequests) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable>> SortAndPage(this System.IObservable> source, System.Collections.Generic.IComparer comparer, System.IObservable pageRequests, DynamicData.SortAndPageOptions options) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable>> SortAndPage(this System.IObservable> source, System.IObservable> comparerChanged, System.IObservable pageRequests, DynamicData.SortAndPageOptions options) + where TObject : notnull + where TKey : notnull { } public static System.IObservable>> SortAndVirtualize(this System.IObservable> source, System.Collections.Generic.IComparer comparer, System.IObservable virtualRequests) where TObject : notnull where TKey : notnull { } @@ -1894,10 +1923,11 @@ namespace DynamicData where TObject : notnull where TKey : notnull where TSortKey : notnull { } + [System.Obsolete("Use Overload with comparer as it\'s more efficient")] public static System.IObservable> Top(this System.IObservable> source, int size) where TObject : notnull where TKey : notnull { } - public static System.IObservable> Top(this System.IObservable> source, System.Collections.Generic.IComparer comparer, int size) + public static System.IObservable>> Top(this System.IObservable> source, System.Collections.Generic.IComparer comparer, int size) where TObject : notnull where TKey : notnull { } public static System.IObservable> Transform(this System.IObservable> source, System.Func transformFactory, bool transformOnRefresh) @@ -2145,6 +2175,7 @@ namespace DynamicData public static System.IObservable> UpdateIndex(this System.IObservable> source) where TObject : DynamicData.Binding.IIndexAware where TKey : notnull { } + [System.Obsolete("Use SortAndVirtualize as it\'s more efficient")] public static System.IObservable> Virtualise(this System.IObservable> source, System.IObservable virtualRequests) where TObject : notnull where TKey : notnull { } @@ -2510,15 +2541,22 @@ namespace DynamicData where T : notnull { } } public static class ObsoleteEx { } + public class PageContext : System.IEquatable> + { + public PageContext(DynamicData.Operators.IPageResponse Response, System.Collections.Generic.IComparer Comparer, DynamicData.SortAndPageOptions Options) { } + public System.Collections.Generic.IComparer Comparer { get; init; } + public DynamicData.SortAndPageOptions Options { get; init; } + public DynamicData.Operators.IPageResponse Response { get; init; } + } public sealed class PageRequest : DynamicData.IPageRequest, System.IEquatable { public static readonly DynamicData.IPageRequest Default; public static readonly DynamicData.IPageRequest Empty; public PageRequest() { } public PageRequest(int page, int size) { } - public System.Collections.Generic.IEqualityComparer DefaultComparer { get; } public int Page { get; } public int Size { get; } + public static System.Collections.Generic.IEqualityComparer DefaultComparer { get; } public bool Equals(DynamicData.IPageRequest? other) { } public override bool Equals(object? obj) { } public override int GetHashCode() { } @@ -2536,6 +2574,13 @@ namespace DynamicData public void SetStartingIndex(int index) { } public override string ToString() { } } + public struct SortAndPageOptions : System.IEquatable + { + public SortAndPageOptions() { } + public int InitialCapacity { get; init; } + public int ResetThreshold { get; init; } + public bool UseBinarySearch { get; init; } + } public struct SortAndVirtualizeOptions : System.IEquatable { public SortAndVirtualizeOptions() { } diff --git a/src/DynamicData.Tests/Cache/SortAndPageAndBindFixture.cs b/src/DynamicData.Tests/Cache/SortAndPageAndBindFixture.cs new file mode 100644 index 000000000..3b3466c85 --- /dev/null +++ b/src/DynamicData.Tests/Cache/SortAndPageAndBindFixture.cs @@ -0,0 +1,390 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Subjects; +using DynamicData.Binding; +using DynamicData.Tests.Domain; +using FluentAssertions; +using Xunit; + +namespace DynamicData.Tests.Cache; + + +public sealed class SortAndPageAndBindWithImplicitOptionsFixtureReadOnlyCollection : SortAndPageAndBindFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + + var aggregator = Source.Connect() + .SortAndPage(Comparer, PageRequests) + // no sort and bind options. These are extracted from the SortAndPage context + .Bind(out var list) + .AsAggregator(); + + return (aggregator, list); + } +} + +public sealed class SortAndPageAndBindFixtureReadOnlyCollection : SortAndPageAndBindFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + + var aggregator = Source.Connect() + .SortAndPage(Comparer, PageRequests) + .Bind(out var list, new SortAndBindOptions()) + .AsAggregator(); + + return (aggregator, list); + } +} + +public sealed class SortAndPageAndBindWithImplicitOptionsFixture : SortAndPageAndBindFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + var list = new List(); + + var aggregator = Source.Connect() + .SortAndPage(Comparer, PageRequests) + // no sort and bind options. These are extracted from the SortAndPage context + .Bind(list) + .AsAggregator(); + + return (aggregator, list); + } +} + +public sealed class SortAndPageAndBindFixture : SortAndPageAndBindFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + var list = new List(); + + var aggregator = Source.Connect() + .SortAndPage(Comparer, PageRequests) + .SortAndBind(list, new SortAndBindOptions()) + .AsAggregator(); + + return (aggregator, list); + } +} + +public abstract class SortAndPageAndBindFixtureBase : IDisposable +{ + + protected readonly SourceCache Source = new(p => p.Name); + protected readonly IComparer Comparer = SortExpressionComparer.Ascending(p => p.Age).ThenByAscending(p => p.Name); + protected readonly ISubject PageRequests = new BehaviorSubject(new PageRequest(0, 25)); + + protected readonly ChangeSetAggregator Aggregator; + protected readonly IList List; + + protected SortAndPageAndBindFixtureBase() + { + // It's ok in this case to call VirtualMemberCallInConstructor + +#pragma warning disable CA2214 + // ReSharper disable once VirtualMemberCallInConstructor + var args = SetUpTests(); +#pragma warning restore CA2214 + + Aggregator = args.aggregator; + List = args.list; + } + + + protected abstract (ChangeSetAggregator aggregator, IList list) SetUpTests(); + + + [Fact] + public void PageGreaterThanNumberOfPagesAvailable() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()); + Source.AddOrUpdate(people); + + // should select the last page + PageRequests.OnNext(new PageRequest(10, 25)); + + var expectedResult = people.OrderBy(p => p, Comparer).Skip(75).Take(25).ToList(); + + List.Should().BeEquivalentTo(expectedResult); + } + + + [Fact] + public void OverlappingShift() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()); + Source.AddOrUpdate(people); + + PageRequests.OnNext(new PageRequest(3, 10)); + + // for first batch, it should use the results of the _PageRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Skip(20).Take(10).ToList(); + List.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public void AddFirstInRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at beginning + var person = new Person("_FirstPerson", 1); + Source.AddOrUpdate(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(new Person("P025", 25)); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(person); + + // check for correctness of resulting collection + people.Add(person); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + List.SequenceEqual(expectedResult).Should().Be(true); + } + + + [Fact] + public void AddOutsideOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at end + var person = new Person("X_Last", 100); + Source.AddOrUpdate(person); + + // only the initials message should have been received + Aggregator.Messages.Count.Should().Be(1); + + + people.Add(person); + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + List.SequenceEqual(expectedResult).Should().Be(true); + } + + [Fact] + public void UpdateMoveOutOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // Change an item so it moves from in range to out of range + var person = new Person("P012", 50); + Source.AddOrUpdate(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(new Person("P012", 50)); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(new Person("P026", 26)); + + // check for correctness of resulting collection + people = people.OrderBy(p => p, Comparer).ToList(); + people[11] = person; + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + List.SequenceEqual(expectedResult).Should().Be(true); + } + [Fact] + public void UpdateStayRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // Update an item, but keep it withing the expected virtual range. + var person = new Person("P012", -1); + Source.AddOrUpdate(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(1); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Update); + firstChange.Current.Should().Be(new Person("P012", -1)); + firstChange.Previous.Value.Should().Be(new Person("P012", 12)); + + // check for correctness of resulting collection + people = people.OrderBy(p => p, Comparer).ToList(); + people[11] = person; + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + List.SequenceEqual(expectedResult).Should().Be(true); + } + + + + [Fact] + public void UpdateOutOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at beginning + var person = new Person("P050", 100); + Source.AddOrUpdate(person); + + // only the initials message should have been received + Aggregator.Messages.Count.Should().Be(1); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + + List.SequenceEqual(expectedResult).Should().Be(true); + } + + + [Fact] + public void RemoveRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // remove an element from the active range + var person = new Person("P012", 12); + Source.Remove(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(person); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(new Person("P026", 26)); + + // check for correctness of resulting collection + people.Remove(person); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + + List.SequenceEqual(expectedResult).Should().Be(true); + } + + [Fact] + public void RemoveOutOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at beginning + var person = new Person("P050", 50); + Source.Remove(person); + + // only the initials message should have been received + Aggregator.Messages.Count.Should().Be(1); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + + List.SequenceEqual(expectedResult).Should().Be(true); + } + + + [Fact] + public void RefreshInRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + var person = people.Single(p => p.Name == "P012"); + Source.Refresh(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(1); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Refresh); + } + + [Fact] + public void RefreshWithInlineChangeInRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + var person = people.Single(p => p.Name == "P012"); + + // The item will move within the virtual range, so be propagated as a refresh + person.Age = 5; + Source.Refresh(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(1); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Refresh); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + + List.SequenceEqual(expectedResult).Should().Be(true); + } + + [Fact] + public void RefreshWithInlineChangeOutsideRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + var person = people.Single(p => p.Name == "P012"); + + // The item will move outside the virtual range, resulting in a remove and index shift + person.Age = 50; + Source.Refresh(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(new Person("P012", 50)); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(new Person("P026", 26)); + + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + + List.SequenceEqual(expectedResult).Should().Be(true); + } + + + public void Dispose() + { + Source.Dispose(); + Aggregator.Dispose(); + PageRequests.OnCompleted(); + } +} diff --git a/src/DynamicData.Tests/Cache/SortAndPageFixture.cs b/src/DynamicData.Tests/Cache/SortAndPageFixture.cs new file mode 100644 index 000000000..dc99855f4 --- /dev/null +++ b/src/DynamicData.Tests/Cache/SortAndPageFixture.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Subjects; +using System.Text; +using System.Threading.Tasks; +using DynamicData.Binding; +using DynamicData.Tests.Domain; +using FluentAssertions; +using Xunit; + +namespace DynamicData.Tests.Cache; + +public sealed class SortAndPageWithComparerChangesFixture : SortAndPageFixtureBase +{ + private BehaviorSubject> _comparerSubject; + + private readonly IComparer _descComparer = SortExpressionComparer.Descending(p => p.Age).ThenByAscending(p => p.Name); + + protected override ChangeSetAggregator> SetUpTests() + { + _comparerSubject = new BehaviorSubject>(Comparer); + + return Source.Connect() + .SortAndPage(_comparerSubject, PageRequests) + .AsAggregator(); + } + + [Fact] + public void ChangeComparer() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()); + Source.AddOrUpdate(people); + + // for first batch, it should use the results of the _PageRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.Should().BeEquivalentTo(expectedResult); + + // change the comparer + _comparerSubject.OnNext(_descComparer); + + expectedResult = people.OrderBy(p => p, _descComparer).Take(25).ToList(); + actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.Should().BeEquivalentTo(expectedResult); + } +} + +public sealed class SortAndPageFixture : SortAndPageFixtureBase +{ + protected override ChangeSetAggregator> SetUpTests() => + Source.Connect() + .SortAndPage(Comparer, PageRequests) + .AsAggregator(); +} + +public abstract class SortAndPageFixtureBase : IDisposable +{ + + protected readonly SourceCache Source = new(p => p.Name); + protected readonly IComparer Comparer = SortExpressionComparer.Ascending(p => p.Age).ThenByAscending(p => p.Name); + protected readonly ISubject PageRequests = new BehaviorSubject(new PageRequest(1, 25)); + + protected readonly ChangeSetAggregator> Aggregator; + + + protected SortAndPageFixtureBase() + { + // It's ok in this case to call VirtualMemberCallInConstructor + +#pragma warning disable CA2214 + // ReSharper disable once VirtualMemberCallInConstructor + Aggregator = SetUpTests(); +#pragma warning restore CA2214 + } + + + protected abstract ChangeSetAggregator> SetUpTests(); + + + [Fact] + public void InitialBatches() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()); + Source.AddOrUpdate(people); + + // for first batch, it should use the results of the _PageRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.Should().BeEquivalentTo(expectedResult); + + + PageRequests.OnNext(new PageRequest(3, 25)); + + expectedResult = people.OrderBy(p => p, Comparer).Skip(50).Take(25).ToList(); + actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public void ThrowsForNegativePage() => Assert.Throws(() => PageRequests.OnNext(new PageRequest(-1, 1))); + + [Fact] + public void ThrowsForNegativeSizeParameters() => Assert.Throws(() => PageRequests.OnNext(new PageRequest(1, -1))); + + + + [Fact] + public void PageGreaterThanNumberOfPagesAvailable() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()); + Source.AddOrUpdate(people); + + // should select the last page + PageRequests.OnNext(new PageRequest(10, 25)); + + var expectedResult = people.OrderBy(p => p, Comparer).Skip(75).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.Should().BeEquivalentTo(expectedResult); + } + + + [Fact] + public void OverlappingShift() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()); + Source.AddOrUpdate(people); + + PageRequests.OnNext(new PageRequest(3, 10)); + + // for first batch, it should use the results of the _PageRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Skip(20).Take(10).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public void AddFirstInRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at beginning + var person = new Person("_FirstPerson", 1); + Source.AddOrUpdate(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(new Person("P025", 25)); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(person); + + // check for correctness of resulting collection + people.Add(person); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + + [Fact] + public void AddOutsideOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at end + var person = new Person("X_Last", 100); + Source.AddOrUpdate(person); + + // only the initials message should have been received + Aggregator.Messages.Count.Should().Be(1); + + + people.Add(person); + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + [Fact] + public void UpdateMoveOutOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // Change an item so it moves from in range to out of range + var person = new Person("P012", 50); + Source.AddOrUpdate(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(new Person("P012", 50)); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(new Person("P026", 26)); + + // check for correctness of resulting collection + people = people.OrderBy(p => p, Comparer).ToList(); + people[11] = person; + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + [Fact] + public void UpdateStayRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // Update an item, but keep it withing the expected virtual range. + var person = new Person("P012", -1); + Source.AddOrUpdate(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(1); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Update); + firstChange.Current.Should().Be(new Person("P012", -1)); + firstChange.Previous.Value.Should().Be(new Person("P012", 12)); + + // check for correctness of resulting collection + people = people.OrderBy(p => p, Comparer).ToList(); + people[11] = person; + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + + + [Fact] + public void UpdateOutOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at beginning + var person = new Person("P050", 100); + Source.AddOrUpdate(person); + + // only the initials message should have been received + Aggregator.Messages.Count.Should().Be(1); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + + [Fact] + public void RemoveRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // remove an element from the active range + var person = new Person("P012", 12); + Source.Remove(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(person); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(new Person("P026", 26)); + + // check for correctness of resulting collection + people.Remove(person); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + [Fact] + public void RemoveOutOfRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + // insert right at beginning + var person = new Person("P050", 50); + Source.Remove(person); + + // only the initials message should have been received + Aggregator.Messages.Count.Should().Be(1); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + + [Fact] + public void RefreshInRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + var person = people.Single(p => p.Name == "P012"); + Source.Refresh(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(1); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Refresh); + } + + [Fact] + public void RefreshWithInlineChangeInRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + var person = people.Single(p => p.Name == "P012"); + + // The item will move within the virtual range, so be propagated as a refresh + person.Age = 5; + Source.Refresh(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(1); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Refresh); + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + [Fact] + public void RefreshWithInlineChangeOutsideRange() + { + var people = Enumerable.Range(1, 100).Select(i => new Person($"P{i:000}", i)).OrderBy(p => Guid.NewGuid()).ToList(); + Source.AddOrUpdate(people); + + var person = people.Single(p => p.Name == "P012"); + + // The item will move outside the virtual range, resulting in a remove and index shift + person.Age = 50; + Source.Refresh(person); + + Aggregator.Messages.Count.Should().Be(2); + + var changes = Aggregator.Messages[1]; + changes.Count.Should().Be(2); + + var firstChange = changes.First(); + firstChange.Reason.Should().Be(ChangeReason.Remove); + firstChange.Current.Should().Be(new Person("P012", 50)); + + var secondChange = changes.Skip(1).First(); + secondChange.Reason.Should().Be(ChangeReason.Add); + secondChange.Current.Should().Be(new Person("P026", 26)); + + + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + var actualResult = Aggregator.Data.Items.OrderBy(p => p, Comparer); + actualResult.SequenceEqual(expectedResult).Should().Be(true); + } + + public void Dispose() + { + Source.Dispose(); + Aggregator.Dispose(); + PageRequests.OnCompleted(); + } +} diff --git a/src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs b/src/DynamicData.Tests/Cache/SortAndVirtualizeAndBindFixture.cs similarity index 94% rename from src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs rename to src/DynamicData.Tests/Cache/SortAndVirtualizeAndBindFixture.cs index c4c352f1b..bb8896aac 100644 --- a/src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs +++ b/src/DynamicData.Tests/Cache/SortAndVirtualizeAndBindFixture.cs @@ -10,7 +10,7 @@ namespace DynamicData.Tests.Cache; -public sealed class SortAndBindVirtualizeWithImplicitOptionsFixtureReadOnlyCollection : SortAndBindVirtualizeFixtureBase +public sealed class SortAndVirtualizeAndBindWithImplicitOptionsFixtureReadOnlyCollection : SortAndVirtualizeAndBindFixtureBase { protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() { @@ -18,28 +18,28 @@ protected override (ChangeSetAggregator aggregator, IList aggregator, IList list) SetUpTests() { var aggregator = Source.Connect() .SortAndVirtualize(Comparer, VirtualRequests) - .SortAndBind(out var list, new SortAndBindOptions()) + .Bind(out var list, new SortAndBindOptions()) .AsAggregator(); return (aggregator, list); } } -public sealed class SortAndBindVirtualizeWithImplicitOptionsFixture : SortAndBindVirtualizeFixtureBase +public sealed class SortAndVirtualizeAndBindWithImplicitOptionsFixture : SortAndVirtualizeAndBindFixtureBase { protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() { @@ -48,14 +48,14 @@ protected override (ChangeSetAggregator aggregator, IList aggregator, IList list) SetUpTests() { @@ -70,7 +70,7 @@ protected override (ChangeSetAggregator aggregator, IList Source = new(p => p.Name); @@ -80,7 +80,7 @@ public abstract class SortAndBindVirtualizeFixtureBase : IDisposable protected readonly ChangeSetAggregator Aggregator; protected readonly IList List; - protected SortAndBindVirtualizeFixtureBase() + protected SortAndVirtualizeAndBindFixtureBase() { // It's ok in this case to call VirtualMemberCallInConstructor diff --git a/src/DynamicData/Binding/BindPaged.cs b/src/DynamicData/Binding/BindPaged.cs new file mode 100644 index 000000000..c8d309a30 --- /dev/null +++ b/src/DynamicData/Binding/BindPaged.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace DynamicData.Binding; + +/* + * Binding for the result of the SortAndPage operator + * + * (Direct lift from BindVirtualized). + */ +internal sealed class BindPaged( + IObservable>> source, + IList targetList, + SortAndBindOptions? options) + where TObject : notnull + where TKey : notnull +{ + public IObservable> Run() => options is null + ? UseContextSortOptions() + : UseProvidedOptions(options.Value); + + private IObservable> UseProvidedOptions(SortAndBindOptions sortAndBindOptions) => + source.Publish(changes => + { + var comparedChanged = changes + .Select(changesWithContext => changesWithContext.Context.Comparer) + .DistinctUntilChanged(); + + return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions); + }); + + private IObservable> UseContextSortOptions() => + Observable.Create>(observer => + { + var shared = source.Publish(); + + var subscriber = new SingleAssignmentDisposable(); + + // I tried to make this work without subjects but had issues + // making the comparedChanged observable to fire. Probably a deadlock + var changesSubject = new Subject>(); + var comparerSubject = new ReplaySubject>(1); + + // once we have the initial values, publish as normal. + var subsequent = shared + .Skip(1) + .Subscribe(changesWithContext => + { + comparerSubject.OnNext(changesWithContext.Context.Comparer); + changesSubject.OnNext(changesWithContext); + }); + + // extract binding options from the page context + var initial = shared + .Take(1) + .Subscribe(changesWithContext => + { + var virtualOptions = changesWithContext.Context.Options; + var extractedOptions = DynamicDataOptions.SortAndBind with + { + UseBinarySearch = virtualOptions.UseBinarySearch, + ResetThreshold = virtualOptions.ResetThreshold + }; + + subscriber.Disposable = changesSubject + .SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions) + .SubscribeSafe(observer); + + comparerSubject.OnNext(changesWithContext.Context.Comparer); + changesSubject.OnNext(changesWithContext); + }); + + return new CompositeDisposable(initial, subscriber, subsequent, shared.Connect()); + }); +} diff --git a/src/DynamicData/Binding/SortAndBindVirtualized.cs b/src/DynamicData/Binding/BindVirtualized.cs similarity index 98% rename from src/DynamicData/Binding/SortAndBindVirtualized.cs rename to src/DynamicData/Binding/BindVirtualized.cs index 70b8eb931..dac893e5a 100644 --- a/src/DynamicData/Binding/SortAndBindVirtualized.cs +++ b/src/DynamicData/Binding/BindVirtualized.cs @@ -11,7 +11,7 @@ namespace DynamicData.Binding; /* * Binding for the result of the SortAndVirtualize operator */ -internal sealed class SortAndBindVirtualized( +internal sealed class BindVirtualized( IObservable>> source, IList targetList, SortAndBindOptions? options) diff --git a/src/DynamicData/Cache/Internal/SortAndPage.cs b/src/DynamicData/Cache/Internal/SortAndPage.cs new file mode 100644 index 000000000..ab924bfd9 --- /dev/null +++ b/src/DynamicData/Cache/Internal/SortAndPage.cs @@ -0,0 +1,195 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using DynamicData.Binding; + +namespace DynamicData.Cache.Internal; + +internal sealed class SortAndPage + where TObject : notnull + where TKey : notnull +{ + private static readonly KeyComparer _keyComparer = new(); + + private readonly IObservable> _source; + private readonly IObservable> _comparerChanged; + private readonly IObservable _pageRequests; + private readonly SortAndPageOptions _options; + + public SortAndPage(IObservable> source, + IComparer comparer, + IObservable virtualRequests, + SortAndPageOptions options) + : this(source, Observable.Return(comparer), virtualRequests, options) + { + } + + public SortAndPage(IObservable> source, + IObservable> comparerChanged, + IObservable virtualRequests, + SortAndPageOptions options) + { + _options = options; + _source = source ?? throw new ArgumentNullException(nameof(source)); + _comparerChanged = comparerChanged; + _pageRequests = virtualRequests ?? throw new ArgumentNullException(nameof(virtualRequests)); + } + + private static readonly ChangeSet> Empty = new(0, PageContext.Empty); + + public IObservable>> Run() => + Observable.Create>>( + observer => + { + var locker = new object(); + + var sortOptions = new SortAndBindOptions + { + UseBinarySearch = _options.UseBinarySearch, + ResetThreshold = _options.ResetThreshold + }; + + var pageRequest = PageRequest.Default; + + // a sorted list of key value pairs + var sortedList = new List>(_options.InitialCapacity); + var pagedItems = new List>(pageRequest.Size); + + IComparer? comparer = null; + KeyValueComparer? keyValueComparer = null; + SortedKeyValueApplicator? applicator = null; + + // used to maintain a sorted list of key value pairs + var comparerChanged = _comparerChanged.Synchronize(locker) + .Select(c => + { + comparer = c; + keyValueComparer = new KeyValueComparer(c); + + if (applicator is null) + { + applicator = new SortedKeyValueApplicator(sortedList, keyValueComparer, sortOptions); + } + else + { + applicator.ChangeComparer(keyValueComparer); + } + return ApplyPagedChanges(); + }); + + var paramsChanged = _pageRequests.Synchronize(locker) + .DistinctUntilChanged() + // exclude dodgy params + .Where(parameters => parameters is { Page: > 0, Size: > 0 }) + .Select(request => + { + pageRequest = request; + + // have not received the comparer yet + if (applicator is null) return Empty; + + // re-apply virtual changes + return ApplyPagedChanges(); + }); + + var dataChange = _source.Synchronize(locker) + // we need to ensure each change batch has unique keys only. + // Otherwise, calculation of virtualized changes is super complex + .EnsureUniqueKeys() + .Select(changes => + { + // have not received the comparer yet + if (applicator is null) return Empty; + + // apply changes to the sorted list + applicator.ProcessChanges(changes); + + // re-apply virtual changes + return ApplyPagedChanges(changes); + }); + + return + comparerChanged + .Merge(paramsChanged) + .Merge(dataChange) + .Where(changes => changes.Count is not 0) + .SubscribeSafe(observer); + + ChangeSet> ApplyPagedChanges(IChangeSet? changeSet = null) + { + var previousVirtualList = pagedItems; + + var listSize = sortedList.Count; + var pages = CalculatePages(pageRequest, listSize); + var page = pageRequest.Page > pages ? pages : pageRequest.Page; + var skip = pageRequest.Size * (page - 1); + + // re-calculate virtual changes + var currentPagesItems = new List>(pageRequest.Size); + currentPagesItems.AddRange(sortedList.Skip(skip).Take(pageRequest.Size)); + + var responseParams = new PageResponse(pageRequest.Size, listSize, page, pages); + var context = new PageContext(responseParams, comparer!, _options); + + // calculate notifications + var virtualChanges = CalculatePageChanges(context, currentPagesItems, previousVirtualList, changeSet); + + pagedItems = currentPagesItems; + + return virtualChanges; + } + + static int CalculatePages(IPageRequest request, int totalCount) + { + if (request.Size >= totalCount) + { + return 1; + } + + var pages = totalCount / request.Size; + var overlap = totalCount % request.Size; + + if (overlap == 0) + { + return pages; + } + + return pages + 1; + } + }); + + // Calculates any changes within the paged range. + private static ChangeSet> CalculatePageChanges(PageContext context, + List> currentItems, + List> previousItems, + IChangeSet? changes = null) + { + var result = new ChangeSet>(currentItems.Count * 2, context); + + var removes = previousItems.Except(currentItems, _keyComparer).Select(kvp => new Change(ChangeReason.Remove, kvp.Key, kvp.Value)); + var adds = currentItems.Except(previousItems, _keyComparer).Select(kvp => new Change(ChangeReason.Add, kvp.Key, kvp.Value)); + + result.AddRange(removes); + result.AddRange(adds); + + if (changes is null) return result; + + var keyInPreviousAndCurrent = new HashSet(previousItems.Intersect(currentItems, _keyComparer).Select(x => x.Key)); + + foreach (var change in changes) + { + // An update (or refresh) can only occur if it was in the previous or current result set. + // If it was in only one or the other, it would be an add or remove accordingly. + if (!keyInPreviousAndCurrent.Contains(change.Key)) continue; + + if (change.Reason is ChangeReason.Update or ChangeReason.Refresh) + { + result.Add(change); + } + } + + return result; + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs index b73095c8f..c89376a6a 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Collections.ObjectModel; -using System.Diagnostics; using DynamicData.Binding; namespace DynamicData; @@ -13,6 +12,80 @@ namespace DynamicData; /// public static partial class ObservableCacheEx { + /// + /// Bind paged data to the specified readonly observable collection. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The resulting read only observable collection. + /// An observable which will emit change sets. + public static IObservable> Bind( + this IObservable>> source, + out ReadOnlyObservableCollection readOnlyObservableCollection) + where TObject : notnull + where TKey : notnull + { + var targetList = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(targetList); + + return source.Bind(targetList); + } + + /// + /// Bind paged data to the specified collection. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The resulting read only observable collection. + /// Bind and sort default options. + /// An observable which will emit change sets. + public static IObservable> Bind( + this IObservable>> source, + out ReadOnlyObservableCollection readOnlyObservableCollection, + SortAndBindOptions options) + where TObject : notnull + where TKey : notnull + { + var targetList = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(targetList); + + return source.Bind(targetList, options); + } + + /// + /// Bind paged data to the specified collection. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The list to bind to. + /// An observable which will emit change sets. + public static IObservable> Bind( + this IObservable>> source, + IList targetList) + where TObject : notnull + where TKey : notnull => + new BindPaged(source, targetList, null).Run(); + + /// + /// Bind paged data to the specified collection. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The list to bind to. + /// Bind and sort default options. + /// An observable which will emit change sets. + public static IObservable> Bind( + this IObservable>> source, + IList targetList, + SortAndBindOptions options) + where TObject : notnull + where TKey : notnull => + new BindPaged(source, targetList, options).Run(); + /// /// Bind virtualized and sorted data to the specified readonly observable collection. /// @@ -21,7 +94,7 @@ public static partial class ObservableCacheEx /// The source. /// The resulting read only observable collection. /// An observable which will emit change sets. - public static IObservable> SortAndBind( + public static IObservable> Bind( this IObservable>> source, out ReadOnlyObservableCollection readOnlyObservableCollection) where TObject : notnull @@ -30,7 +103,7 @@ public static IObservable> SortAndBind( var targetList = new ObservableCollectionExtended(); readOnlyObservableCollection = new ReadOnlyObservableCollection(targetList); - return source.SortAndBind(targetList); + return source.Bind(targetList); } /// @@ -42,7 +115,7 @@ public static IObservable> SortAndBind( /// The resulting read only observable collection. /// Bind and sort default options. /// An observable which will emit change sets. - public static IObservable> SortAndBind( + public static IObservable> Bind( this IObservable>> source, out ReadOnlyObservableCollection readOnlyObservableCollection, SortAndBindOptions options) @@ -52,7 +125,7 @@ public static IObservable> SortAndBind( var targetList = new ObservableCollectionExtended(); readOnlyObservableCollection = new ReadOnlyObservableCollection(targetList); - return source.SortAndBind(targetList, options); + return source.Bind(targetList, options); } /// @@ -63,12 +136,12 @@ public static IObservable> SortAndBind( /// The source. /// The list to bind to. /// An observable which will emit change sets. - public static IObservable> SortAndBind( + public static IObservable> Bind( this IObservable>> source, IList targetList) where TObject : notnull where TKey : notnull => - new SortAndBindVirtualized(source, targetList, null).Run(); + new BindVirtualized(source, targetList, null).Run(); /// /// Bind virtualized data to the specified collection. @@ -79,13 +152,13 @@ public static IObservable> SortAndBind( /// The list to bind to. /// Bind and sort default options. /// An observable which will emit change sets. - public static IObservable> SortAndBind( + public static IObservable> Bind( this IObservable>> source, IList targetList, SortAndBindOptions options) where TObject : notnull where TKey : notnull => - new SortAndBindVirtualized(source, targetList, options).Run(); + new BindVirtualized(source, targetList, options).Run(); /// /// Bind sorted data to the specified collection, for an object which implements IComparable>. diff --git a/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs b/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs index a9246b4c2..6d54897f8 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs @@ -111,6 +111,7 @@ public static IObservable>> So /// The virtualising requests. /// An observable which will emit virtual change sets. /// source. + [Obsolete(Constants.VirtualizeIsObsolete)] public static IObservable> Virtualise(this IObservable> source, IObservable virtualRequests) where TObject : notnull where TKey : notnull @@ -122,53 +123,144 @@ public static IObservable> Virtualise - /// Limits the size of the result set to the specified number. + /// Limits the size of the result set to the specified number, ordering by the comparer. /// /// The type of the object. /// The type of the key. /// The source. + /// The comparer. /// The size. /// An observable which will emit virtual change sets. /// source. /// size;Size should be greater than zero. - public static IObservable> Top(this IObservable> source, int size) + public static IObservable>> Top(this IObservable> source, IComparer comparer, int size) where TObject : notnull where TKey : notnull { source.ThrowArgumentNullExceptionIfNull(nameof(source)); + comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); if (size <= 0) { throw new ArgumentOutOfRangeException(nameof(size), "Size should be greater than zero"); } - return new Virtualise(source, Observable.Return(new VirtualRequest(0, size))).Run(); + return source.SortAndVirtualize(comparer, Observable.Return(new VirtualRequest(0, size))); } /// - /// Limits the size of the result set to the specified number, ordering by the comparer. + /// Limits the size of the result set to the specified number. /// /// The type of the object. /// The type of the key. /// The source. - /// The comparer. /// The size. /// An observable which will emit virtual change sets. /// source. /// size;Size should be greater than zero. - public static IObservable> Top(this IObservable> source, IComparer comparer, int size) + [Obsolete(Constants.TopIsObsolete)] + public static IObservable> Top(this IObservable> source, int size) where TObject : notnull where TKey : notnull { source.ThrowArgumentNullExceptionIfNull(nameof(source)); - comparer.ThrowArgumentNullExceptionIfNull(nameof(comparer)); if (size <= 0) { throw new ArgumentOutOfRangeException(nameof(size), "Size should be greater than zero"); } - return source.Sort(comparer).Top(size); + return new Virtualise(source, Observable.Return(new VirtualRequest(0, size))).Run(); + } + + /// + /// Sort and page the underlying data from the specified source. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The comparer to order the resulting dataset. + /// The virtualizing requests. + /// An observable which will emit virtual change sets. + /// source. + public static IObservable>> SortAndPage(this IObservable> source, + IComparer comparer, + IObservable pageRequests) + where TObject : notnull + where TKey : notnull => + source.SortAndPage(comparer, pageRequests, new SortAndPageOptions()); + + /// + /// Sort and page the underlying data from the specified source. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// An observable of comparers which enables the sort order to be changed.> + /// The virtualizing requests. + /// An observable which will emit virtual change sets. + /// source. + public static IObservable>> SortAndPage( + this IObservable> source, + IObservable> comparerChanged, + IObservable pageRequests) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pageRequests.ThrowArgumentNullExceptionIfNull(nameof(pageRequests)); + + return source.SortAndPage(comparerChanged, pageRequests, new SortAndPageOptions()); + } + + /// + /// Sort and page the underlying data from the specified source. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The comparer to order the resulting dataset. + /// The virtualizing requests. + /// Addition optimization options for virtualization. + /// An observable which will emit virtual change sets. + /// source. + public static IObservable>> SortAndPage( + this IObservable> source, + IComparer comparer, + IObservable pageRequests, + SortAndPageOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pageRequests.ThrowArgumentNullExceptionIfNull(nameof(pageRequests)); + + return new SortAndPage(source, comparer, pageRequests, options).Run(); + } + + /// + /// Sort and page the underlying data from the specified source. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// An observable of comparers which enables the sort order to be changed.> + /// The virtualizing requests. + /// Addition optimization options for virtualization. + /// An observable which will emit virtual change sets. + /// source. + public static IObservable>> SortAndPage( + this IObservable> source, + IObservable> comparerChanged, + IObservable pageRequests, + SortAndPageOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pageRequests.ThrowArgumentNullExceptionIfNull(nameof(pageRequests)); + + return new SortAndPage(source, comparerChanged, pageRequests, options).Run(); } /// @@ -179,6 +271,7 @@ public static IObservable> Top(t /// The source. /// The page requests. /// An observable which emits change sets. + [Obsolete(Constants.PageIsObsolete)] public static IObservable> Page(this IObservable> source, IObservable pageRequests) where TObject : notnull where TKey : notnull diff --git a/src/DynamicData/Cache/ObservableCacheEx.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index 284ecd275..e969b914e 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -4091,6 +4091,7 @@ public static IObservable> SkipInitial( /// or /// comparer. /// + [Obsolete(Constants.SortIsObsolete)] public static IObservable> Sort(this IObservable> source, IComparer comparer, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) where TObject : notnull where TKey : notnull @@ -4111,6 +4112,7 @@ public static IObservable> Sort(t /// The sort optimisations. /// The reset threshold. /// An observable which emits change sets. + [Obsolete(Constants.SortIsObsolete)] public static IObservable> Sort(this IObservable> source, IObservable> comparerObservable, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) where TObject : notnull where TKey : notnull @@ -4132,6 +4134,7 @@ public static IObservable> Sort(t /// The sort optimisations. /// The reset threshold. /// An observable which emits change sets. + [Obsolete(Constants.SortIsObsolete)] public static IObservable> Sort(this IObservable> source, IObservable> comparerObservable, IObservable resorter, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) where TObject : notnull where TKey : notnull @@ -4153,6 +4156,7 @@ public static IObservable> Sort(t /// The sort optimisations. /// The reset threshold. /// An observable which emits change sets. + [Obsolete(Constants.SortIsObsolete)] public static IObservable> Sort(this IObservable> source, IComparer comparer, IObservable resorter, SortOptimisations sortOptimisations = SortOptimisations.None, int resetThreshold = DefaultSortResetThreshold) where TObject : notnull where TKey : notnull diff --git a/src/DynamicData/Cache/PageContext.cs b/src/DynamicData/Cache/PageContext.cs new file mode 100644 index 000000000..15f34c1a3 --- /dev/null +++ b/src/DynamicData/Cache/PageContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using DynamicData.Operators; + +namespace DynamicData; + +/// +/// Parameters associated with the page operation. +/// +/// The type of object. +/// Response parameters. +/// The comparer used to order the items. +/// The options used to perform virtualization. +public record PageContext( + IPageResponse Response, + IComparer Comparer, + SortAndPageOptions Options) +{ + internal static readonly PageContext Empty = new + ( + new PageResponse(0, 0, 0, 0), + Comparer.Default, + new SortAndPageOptions() + ); +} diff --git a/src/DynamicData/Cache/PageRequest.cs b/src/DynamicData/Cache/PageRequest.cs index 36f49463f..da9a70723 100644 --- a/src/DynamicData/Cache/PageRequest.cs +++ b/src/DynamicData/Cache/PageRequest.cs @@ -22,8 +22,6 @@ public sealed class PageRequest : IPageRequest, IEquatable /// public static readonly IPageRequest Empty = new PageRequest(0, 0); - private static readonly IEqualityComparer _pageSizeComparerInstance = new PageSizeEqualityComparer(); - /// /// Initializes a new instance of the class. /// @@ -58,8 +56,7 @@ public PageRequest() /// /// The default comparer. /// - [SuppressMessage("Design", "CA1822: Member can be static", Justification = "Backwards compatibilty")] - public IEqualityComparer DefaultComparer => _pageSizeComparerInstance; + public static IEqualityComparer DefaultComparer { get; } = new PageSizeEqualityComparer(); /// /// Gets the page to move to. diff --git a/src/DynamicData/Cache/SortAndPageOptions.cs b/src/DynamicData/Cache/SortAndPageOptions.cs new file mode 100644 index 000000000..9722b139a --- /dev/null +++ b/src/DynamicData/Cache/SortAndPageOptions.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using DynamicData.Binding; + +namespace DynamicData; + +/// +/// Options for the sort and virtualize operator. +/// +public record struct SortAndPageOptions() +{ + /// + /// The sort reset threshold ie the number of changes before a reset is fired. + /// + public int ResetThreshold { get; init; } = BindingOptions.DefaultResetThreshold; + + /// + /// Use binary search when the result of the comparer is a pure function. + /// + public bool UseBinarySearch { get; init; } + + /// + /// Set the initial capacity of internal sorted list. + /// + public int InitialCapacity { get; init; } +} diff --git a/src/DynamicData/Constants.cs b/src/DynamicData/Constants.cs index a2b2abe65..961cb23ba 100644 --- a/src/DynamicData/Constants.cs +++ b/src/DynamicData/Constants.cs @@ -7,4 +7,8 @@ namespace DynamicData; internal static class Constants { public const string EvaluateIsDead = "Use Refresh: Same thing but better semantics"; + public const string VirtualizeIsObsolete = "Use SortAndVirtualize as it's more efficient"; + public const string PageIsObsolete = "Use SortAndPage as it's more efficient"; + public const string TopIsObsolete = "Use Overload with comparer as it's more efficient"; + public const string SortIsObsolete = "Use SortAndBind as it's more efficient"; }