From 275cc2c4202558903f82ce8785267dd9f6f7a82a Mon Sep 17 00:00:00 2001 From: Roland Pheasant Date: Mon, 3 Jun 2024 09:43:17 +0100 Subject: [PATCH] New Virtualization operator (#888) SortAndVirtual + SortAndBind - to replace the old Sort followed by virtualize --- .editorconfig | 16 +- ...ts.DynamicDataTests.DotNet8_0.verified.txt | 71 +++- .../Cache/EnsureUniqueKeysFixture.cs | 74 ++++ .../Cache/SortAndBindVirtualize.cs | 395 ++++++++++++++++++ .../Cache/SortAndVirtualizeFixture.cs | 380 +++++++++++++++++ src/DynamicData/Binding/SortAndBind.cs | 48 +-- src/DynamicData/Binding/SortAndBindOptions.cs | 2 +- .../Binding/SortAndBindVirtualized.cs | 78 ++++ src/DynamicData/Cache/ChangeSet.cs | 38 ++ src/DynamicData/Cache/IChangeSet.cs | 17 + .../Cache/Internal/SortAndVirtualize.cs | 172 ++++++++ .../Cache/Internal/SortExtensions.cs | 54 +++ .../Internal/SortedKeyValueApplicator.cs | 139 ++++++ .../Cache/Internal/UniquenessEnforcer.cs | 38 +- .../Cache/ObservableCacheEx.SortAndBind.cs | 75 ++++ .../ObservableCacheEx.VirtualiseAndPage.cs | 191 +++++++++ src/DynamicData/Cache/ObservableCacheEx.cs | 87 ---- .../Cache/SortAndVirtualizeOptions.cs | 28 ++ .../Cache/Tests/ChangeSetAggregator.cs | 104 +++-- src/DynamicData/Cache/Tests/TestEx.cs | 12 + src/DynamicData/Cache/VirtualContext.cs | 25 ++ src/DynamicData/DynamicData.csproj | 2 +- 22 files changed, 1873 insertions(+), 173 deletions(-) create mode 100644 src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs create mode 100644 src/DynamicData.Tests/Cache/SortAndVirtualizeFixture.cs create mode 100644 src/DynamicData/Binding/SortAndBindVirtualized.cs create mode 100644 src/DynamicData/Cache/Internal/SortAndVirtualize.cs create mode 100644 src/DynamicData/Cache/Internal/SortExtensions.cs create mode 100644 src/DynamicData/Cache/Internal/SortedKeyValueApplicator.cs create mode 100644 src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs create mode 100644 src/DynamicData/Cache/SortAndVirtualizeOptions.cs create mode 100644 src/DynamicData/Cache/VirtualContext.cs diff --git a/.editorconfig b/.editorconfig index e9feebbbc..033ce4908 100644 --- a/.editorconfig +++ b/.editorconfig @@ -98,13 +98,13 @@ dotnet_naming_symbols.constant_fields.required_modifiers = const dotnet_naming_style.pascal_case_style.capitalization = pascal_case # static fields should have s_ prefix -dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.severity = none dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style dotnet_naming_symbols.static_fields.applicable_kinds = field dotnet_naming_symbols.static_fields.required_modifiers = static dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected -dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.required_prefix = _ dotnet_naming_style.static_prefix_style.capitalization = camel_case # internal and private fields should be _camelCase @@ -305,14 +305,14 @@ dotnet_diagnostic.RCS1507.severity = error dotnet_diagnostic.SA1000.severity = error dotnet_diagnostic.SA1001.severity = error -dotnet_diagnostic.SA1002.severity = error +dotnet_diagnostic.SA1002.severity = none dotnet_diagnostic.SA1003.severity = error dotnet_diagnostic.SA1004.severity = error dotnet_diagnostic.SA1005.severity = error dotnet_diagnostic.SA1006.severity = error dotnet_diagnostic.SA1007.severity = error dotnet_diagnostic.SA1008.severity = error -dotnet_diagnostic.SA1009.severity = error +dotnet_diagnostic.SA1009.severity = none dotnet_diagnostic.SA1010.severity = none dotnet_diagnostic.SA1011.severity = error dotnet_diagnostic.SA1012.severity = error @@ -371,9 +371,9 @@ dotnet_diagnostic.SA1137.severity = error dotnet_diagnostic.SA1139.severity = error dotnet_diagnostic.SA1200.severity = none dotnet_diagnostic.SA1201.severity = none -dotnet_diagnostic.SA1202.severity = error -dotnet_diagnostic.SA1203.severity = error -dotnet_diagnostic.SA1204.severity = error +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1203.severity = none +dotnet_diagnostic.SA1204.severity = none dotnet_diagnostic.SA1205.severity = error dotnet_diagnostic.SA1206.severity = error dotnet_diagnostic.SA1207.severity = error @@ -402,7 +402,7 @@ dotnet_diagnostic.SA1314.severity = error dotnet_diagnostic.SA1316.severity = none dotnet_diagnostic.SA1400.severity = error dotnet_diagnostic.SA1401.severity = error -dotnet_diagnostic.SA1402.severity = error +dotnet_diagnostic.SA1402.severity = none dotnet_diagnostic.SA1403.severity = error dotnet_diagnostic.SA1404.severity = error dotnet_diagnostic.SA1405.severity = error 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 ec09eda89..bbf24584b 100644 --- a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt +++ b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt @@ -662,6 +662,15 @@ namespace DynamicData public int Updates { get; } public override string ToString() { } } + public sealed class ChangeSet : DynamicData.ChangeSet, DynamicData.IChangeSet, DynamicData.IChangeSet, DynamicData.IChangeSet, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + where TObject : notnull + where TKey : notnull + { + public ChangeSet(TContext context) { } + public ChangeSet(System.Collections.Generic.IEnumerable> collection, TContext context) { } + public ChangeSet(int capacity, TContext context) { } + public TContext Context { get; } + } public enum ChangeType { Item = 0, @@ -792,6 +801,12 @@ namespace DynamicData { int Updates { get; } } + public interface IChangeSet : DynamicData.IChangeSet, DynamicData.IChangeSet, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + where TObject : notnull + where TKey : notnull + { + TContext Context { get; } + } public interface IConnectableCache where TObject : notnull where TKey : notnull @@ -1766,6 +1781,12 @@ 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 { } @@ -1784,6 +1805,12 @@ 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 { } @@ -1796,6 +1823,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>> SortAndVirtualize(this System.IObservable> source, System.Collections.Generic.IComparer comparer, System.IObservable virtualRequests) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable>> SortAndVirtualize(this System.IObservable> source, System.IObservable> comparerChanged, System.IObservable virtualRequests) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable>> SortAndVirtualize(this System.IObservable> source, System.Collections.Generic.IComparer comparer, System.IObservable virtualRequests, DynamicData.SortAndVirtualizeOptions options) + where TObject : notnull + where TKey : notnull { } + public static System.IObservable>> SortAndVirtualize(this System.IObservable> source, System.IObservable> comparerChanged, System.IObservable virtualRequests, DynamicData.SortAndVirtualizeOptions options) + where TObject : notnull + where TKey : notnull { } public static System.IObservable> SortBy(this System.IObservable> source, System.Func expression, DynamicData.Binding.SortDirection sortOrder = 0, DynamicData.SortOptimisations sortOptimisations = 0, int resetThreshold = 100) where TObject : notnull where TKey : notnull { } @@ -2507,6 +2546,13 @@ namespace DynamicData public void SetStartingIndex(int index) { } public override string ToString() { } } + public struct SortAndVirtualizeOptions : System.IEquatable + { + public SortAndVirtualizeOptions() { } + public int InitialCapacity { get; init; } + public int ResetThreshold { get; init; } + public bool UseBinarySearch { get; init; } + } [System.Serializable] public class SortException : System.Exception { @@ -2626,6 +2672,13 @@ namespace DynamicData public UnspecifiedIndexException(string message) { } public UnspecifiedIndexException(string message, System.Exception innerException) { } } + public class VirtualContext : System.IEquatable> + { + public VirtualContext(DynamicData.IVirtualResponse Response, System.Collections.Generic.IComparer Comparer, DynamicData.SortAndVirtualizeOptions Options) { } + public System.Collections.Generic.IComparer Comparer { get; init; } + public DynamicData.SortAndVirtualizeOptions Options { get; init; } + public DynamicData.IVirtualResponse Response { get; init; } + } public class VirtualRequest : DynamicData.IVirtualRequest, System.IEquatable { public static readonly DynamicData.VirtualRequest Default; @@ -2922,7 +2975,7 @@ namespace DynamicData.Tests public void Dispose() { } protected virtual void Dispose(bool isDisposing) { } } - public class ChangeSetAggregator : System.IDisposable + public sealed class ChangeSetAggregator : System.IDisposable where TObject : notnull where TKey : notnull { @@ -2933,7 +2986,18 @@ namespace DynamicData.Tests public System.Collections.Generic.IList> Messages { get; } public DynamicData.Diagnostics.ChangeSummary Summary { get; } public void Dispose() { } - protected virtual void Dispose(bool isDisposing) { } + } + public sealed class ChangeSetAggregator : System.IDisposable + where TObject : notnull + where TKey : notnull + { + public ChangeSetAggregator(System.IObservable> source) { } + public DynamicData.IObservableCache Data { get; } + public System.Exception? Error { get; } + public bool IsCompleted { get; } + public System.Collections.Generic.IList> Messages { get; } + public DynamicData.Diagnostics.ChangeSummary Summary { get; } + public void Dispose() { } } public class DistinctChangeSetAggregator : System.IDisposable where TValue : notnull @@ -3006,6 +3070,9 @@ namespace DynamicData.Tests public static DynamicData.Tests.VirtualChangeSetAggregator AsAggregator(this System.IObservable> source) where TObject : notnull where TKey : notnull { } + public static DynamicData.Tests.ChangeSetAggregator AsAggregator(this System.IObservable> source) + where TObject : notnull + where TKey : notnull { } public static DynamicData.Tests.GroupChangeSetAggregator AsAggregator(this System.IObservable> source) where TValue : notnull where TKey : notnull diff --git a/src/DynamicData.Tests/Cache/EnsureUniqueKeysFixture.cs b/src/DynamicData.Tests/Cache/EnsureUniqueKeysFixture.cs index a12c914da..fe2d05737 100644 --- a/src/DynamicData.Tests/Cache/EnsureUniqueKeysFixture.cs +++ b/src/DynamicData.Tests/Cache/EnsureUniqueKeysFixture.cs @@ -2,6 +2,7 @@ using System.Linq; using DynamicData.Tests.Domain; using FluentAssertions; +using Mono.Cecil; using Xunit; namespace DynamicData.Tests.Cache; @@ -49,6 +50,79 @@ public void AddAndRemove() } + [Fact] + public void Refresh() + { + _source.AddOrUpdate(new Person("Me", 20)); + + _source.Edit(innerCache => + { + innerCache.Refresh("Me"); + }); + + var message1 = _results.Messages[1]; + message1.Count.Should().Be(1); + message1.First().Current.Age.Should().Be(20); + message1.First().Reason.Should().Be(ChangeReason.Refresh); + + } + + + [Fact] + public void CompoundRefresh1() + { + _source.Edit(innerCache => + { + _source.AddOrUpdate(new Person("Me", 20)); + innerCache.Refresh("Me"); + }); + + var message1 = _results.Messages[0]; + message1.Count.Should().Be(1); + message1.First().Current.Age.Should().Be(20); + message1.First().Reason.Should().Be(ChangeReason.Add); + + } + + [Fact] + public void CompoundRefresh2() + { + _source.Edit(innerCache => + { + innerCache.AddOrUpdate(new Person("Me", 20)); + innerCache.AddOrUpdate(new Person("Me", 21)); + innerCache.Refresh("Me"); + innerCache.Refresh("Me"); + }); + + var message1 = _results.Messages[0]; + message1.Count.Should().Be(1); + message1.First().Current.Age.Should().Be(21); + message1.First().Reason.Should().Be(ChangeReason.Add); + + } + + [Fact] + public void CompoundRefresh3() + { + _source.AddOrUpdate(new Person("Me", 20)); + + _source.Edit(innerCache => + { + + innerCache.Refresh("Me"); + innerCache.Refresh("Me"); + innerCache.Refresh("Me"); + }); + + var message1 = _results.Messages[1]; + message1.Count.Should().Be(1); + message1.First().Current.Age.Should().Be(20); + message1.First().Reason.Should().Be(ChangeReason.Refresh); + + } + + public void Dispose() { _source.Dispose(); diff --git a/src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs b/src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs new file mode 100644 index 000000000..c4c352f1b --- /dev/null +++ b/src/DynamicData.Tests/Cache/SortAndBindVirtualize.cs @@ -0,0 +1,395 @@ +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 SortAndBindVirtualizeWithImplicitOptionsFixtureReadOnlyCollection : SortAndBindVirtualizeFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + + var aggregator = Source.Connect() + .SortAndVirtualize(Comparer, VirtualRequests) + // no sort and bind options. These are extracted from the SortAndVirtualize context + .SortAndBind(out var list) + .AsAggregator(); + + return (aggregator, list); + } +} + +public sealed class SortAndBindVirtualizeFixtureReadOnlyCollection : SortAndBindVirtualizeFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + + var aggregator = Source.Connect() + .SortAndVirtualize(Comparer, VirtualRequests) + .SortAndBind(out var list, new SortAndBindOptions()) + .AsAggregator(); + + return (aggregator, list); + } +} + +public sealed class SortAndBindVirtualizeWithImplicitOptionsFixture : SortAndBindVirtualizeFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + var list = new List(); + + var aggregator = Source.Connect() + .SortAndVirtualize(Comparer, VirtualRequests) + // no sort and bind options. These are extracted from the SortAndVirtualize context + .SortAndBind(list) + .AsAggregator(); + + return (aggregator, list); + } +} + +public sealed class SortAndBindVirtualizeFixture : SortAndBindVirtualizeFixtureBase +{ + protected override (ChangeSetAggregator aggregator, IList list) SetUpTests() + { + var list = new List(); + + var aggregator = Source.Connect() + .SortAndVirtualize(Comparer, VirtualRequests) + .SortAndBind(list, new SortAndBindOptions()) + .AsAggregator(); + + return (aggregator, list); + } +} + +public abstract class SortAndBindVirtualizeFixtureBase : 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 VirtualRequests = new BehaviorSubject(new VirtualRequest(0, 25)); + + protected readonly ChangeSetAggregator Aggregator; + protected readonly IList List; + + protected SortAndBindVirtualizeFixtureBase() + { + // 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 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 _virtualRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Take(25).ToList(); + List.Should().BeEquivalentTo(expectedResult); + + + VirtualRequests.OnNext(new VirtualRequest(25, 50)); + expectedResult = people.OrderBy(p => p, Comparer).Skip(25).Take(50).ToList(); + List.Should().BeEquivalentTo(expectedResult); + + + VirtualRequests.OnNext(new VirtualRequest(40, 50)); + expectedResult = people.OrderBy(p => p, Comparer).Skip(40).Take(50).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); + + VirtualRequests.OnNext(new VirtualRequest(10, 30)); + + // for first batch, it should use the results of the _virtualRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Skip(10).Take(30).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(); + VirtualRequests.OnCompleted(); + } +} diff --git a/src/DynamicData.Tests/Cache/SortAndVirtualizeFixture.cs b/src/DynamicData.Tests/Cache/SortAndVirtualizeFixture.cs new file mode 100644 index 000000000..1fd1b7caa --- /dev/null +++ b/src/DynamicData.Tests/Cache/SortAndVirtualizeFixture.cs @@ -0,0 +1,380 @@ + +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 SortAndVirtualizeWithComparerChangesFixture : SortAndVirtualizeFixtureBase +{ + 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() + .SortAndVirtualize(_comparerSubject, VirtualRequests) + .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 _virtualRequests 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 SortAndVirtualizeFixture : SortAndVirtualizeFixtureBase +{ + protected override ChangeSetAggregator> SetUpTests() => + Source.Connect() + .SortAndVirtualize(Comparer, VirtualRequests) + .AsAggregator(); +} + +public abstract class SortAndVirtualizeFixtureBase : 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 VirtualRequests = new BehaviorSubject(new VirtualRequest(0, 25)); + + protected readonly ChangeSetAggregator> Aggregator; + + + protected SortAndVirtualizeFixtureBase() + { + // 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 _virtualRequests 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); + + + VirtualRequests.OnNext(new VirtualRequest(25,50)); + + expectedResult = people.OrderBy(p => p, Comparer).Skip(25).Take(50).ToList(); + 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); + + VirtualRequests.OnNext(new VirtualRequest(10, 30)); + + // for first batch, it should use the results of the _virtualRequests subject (if a behaviour subject is used). + var expectedResult = people.OrderBy(p => p, Comparer).Skip(10).Take(30).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(); + VirtualRequests.OnCompleted(); + } +} diff --git a/src/DynamicData/Binding/SortAndBind.cs b/src/DynamicData/Binding/SortAndBind.cs index ecb285331..6a6887dc3 100644 --- a/src/DynamicData/Binding/SortAndBind.cs +++ b/src/DynamicData/Binding/SortAndBind.cs @@ -75,7 +75,8 @@ public SortAndBind(IObservable> source, public IObservable> Run() => _sorted; - internal sealed class SortApplicator(Cache cache, + internal sealed class SortApplicator( + Cache cache, IList target, IComparer comparer, SortAndBindOptions options) @@ -185,7 +186,7 @@ private void ApplyChanges(IChangeSet changes) * as that would effectively be swallowing an error. */ var currentIndex = target.IndexOf(item); - var updatedIndex = GetInsertPositionLinear(item); + var updatedIndex = target.GetInsertPositionLinear(item, comparer); // We need to recalibrate as GetInsertPosition includes the current item updatedIndex = currentIndex < updatedIndex ? updatedIndex - 1 : updatedIndex; @@ -204,45 +205,10 @@ private void ApplyChanges(IChangeSet changes) } } - private int GetCurrentPosition(TObject item) - { - var index = options.UseBinarySearch ? target.BinarySearch(item, comparer) : target.IndexOf(item); - - if (index < 0) - { - throw new SortException($"Cannot find item: {typeof(TObject).Name} -> {item} from {target.Count} items"); - } - - return index; - } + private int GetCurrentPosition(TObject item) => + target.GetCurrentPosition(item, comparer, options.UseBinarySearch); - private int GetInsertPosition(TObject item) => options.UseBinarySearch ? GetInsertPositionBinary(item) : GetInsertPositionLinear(item); - - private int GetInsertPositionBinary(TObject item) - { - var index = target.BinarySearch(item, comparer); - var insertIndex = ~index; - - // sort is not returning uniqueness - if (insertIndex < 0) - { - throw new SortException("Binary search has been specified, yet the sort does not yield uniqueness"); - } - - return insertIndex; - } - - private int GetInsertPositionLinear(TObject item) - { - for (var i = 0; i < target.Count; i++) - { - if (comparer.Compare(item, target[i]) < 0) - { - return i; - } - } - - return target.Count; - } + private int GetInsertPosition(TObject item) => + target.GetInsertPosition(item, comparer, options.UseBinarySearch); } } diff --git a/src/DynamicData/Binding/SortAndBindOptions.cs b/src/DynamicData/Binding/SortAndBindOptions.cs index 7d0d4a099..dc060d894 100644 --- a/src/DynamicData/Binding/SortAndBindOptions.cs +++ b/src/DynamicData/Binding/SortAndBindOptions.cs @@ -5,7 +5,7 @@ namespace DynamicData.Binding; /// -/// Options for bind the bind and sort operators. +/// Options for the sort and bind operator. /// public record struct SortAndBindOptions() { diff --git a/src/DynamicData/Binding/SortAndBindVirtualized.cs b/src/DynamicData/Binding/SortAndBindVirtualized.cs new file mode 100644 index 000000000..70b8eb931 --- /dev/null +++ b/src/DynamicData/Binding/SortAndBindVirtualized.cs @@ -0,0 +1,78 @@ +// 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 SortAndVirtualize operator + */ +internal sealed class SortAndBindVirtualized( + IObservable>> source, + IList targetList, + SortAndBindOptions? options) + where TObject : notnull + where TKey : notnull +{ + public IObservable> Run() => options is null + ? UseVirtualSortOptions() + : 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> UseVirtualSortOptions() => + 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 virtual 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/Cache/ChangeSet.cs b/src/DynamicData/Cache/ChangeSet.cs index 27a1a6c58..820addea3 100644 --- a/src/DynamicData/Cache/ChangeSet.cs +++ b/src/DynamicData/Cache/ChangeSet.cs @@ -5,6 +5,44 @@ // ReSharper disable once CheckNamespace namespace DynamicData; +/// +/// A collection of changes with some arbitrary additional context. +/// +/// The type of the object. +/// The type of the key. +/// The additional context. +public sealed class ChangeSet : ChangeSet, IChangeSet + where TObject : notnull + where TKey : notnull +{ + /// + public TContext Context { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The additional context. + public ChangeSet(TContext context) => Context = context; + + /// + /// Initializes a new instance of the class. + /// + /// The collection of items to start the change set with. + /// The additional context. + public ChangeSet(IEnumerable> collection, TContext context) + : base(collection) => + Context = context; + + /// + /// Initializes a new instance of the class. + /// + /// The initial capacity of the change set. + /// The additional context. + public ChangeSet(int capacity, TContext context) + : base(capacity) => + Context = context; +} + /// /// A collection of changes. /// diff --git a/src/DynamicData/Cache/IChangeSet.cs b/src/DynamicData/Cache/IChangeSet.cs index 68d174001..db98ec648 100644 --- a/src/DynamicData/Cache/IChangeSet.cs +++ b/src/DynamicData/Cache/IChangeSet.cs @@ -5,6 +5,23 @@ // ReSharper disable once CheckNamespace namespace DynamicData; +/// +/// A collection of changes with some arbitrary additional context. +/// Changes are always published in the order. +/// +/// The type of the object. +/// The type of the key. +/// The additional context. +public interface IChangeSet : IChangeSet + where TObject : notnull + where TKey : notnull +{ + /// + /// Additional context. + /// + TContext Context { get; } +} + /// /// A collection of changes. /// Changes are always published in the order. diff --git a/src/DynamicData/Cache/Internal/SortAndVirtualize.cs b/src/DynamicData/Cache/Internal/SortAndVirtualize.cs new file mode 100644 index 000000000..7bea393d9 --- /dev/null +++ b/src/DynamicData/Cache/Internal/SortAndVirtualize.cs @@ -0,0 +1,172 @@ +// 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 SortAndVirtualize + where TObject : notnull + where TKey : notnull +{ + private static readonly KeyComparer _keyComparer = new(); + + private readonly IObservable> _source; + private readonly IObservable> _comparerChanged; + private readonly IObservable _virtualRequests; + private readonly SortAndVirtualizeOptions _options; + + public SortAndVirtualize(IObservable> source, + IComparer comparer, + IObservable virtualRequests, + SortAndVirtualizeOptions options) + : this(source, Observable.Return(comparer), virtualRequests, options) + { + } + + public SortAndVirtualize(IObservable> source, + IObservable> comparerChanged, + IObservable virtualRequests, + SortAndVirtualizeOptions options) + { + _options = options; + _source = source ?? throw new ArgumentNullException(nameof(source)); + _comparerChanged = comparerChanged; + _virtualRequests = virtualRequests ?? throw new ArgumentNullException(nameof(virtualRequests)); + } + + private static readonly ChangeSet> Empty = new(0, VirtualContext.Empty); + + public IObservable>> Run() => + Observable.Create>>( + observer => + { + var locker = new object(); + + var sortOptions = new SortAndBindOptions + { + UseBinarySearch = _options.UseBinarySearch, + ResetThreshold = _options.ResetThreshold + }; + + IVirtualRequest virtualParams = VirtualRequest.Default; + + // a sorted list of key value pairs, maintained by + var sortedList = new List>(_options.InitialCapacity); + var virtualItems = new List>(virtualParams.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 ApplyVirtualChanges(); + }); + + var paramsChanged = _virtualRequests.Synchronize(locker) + .DistinctUntilChanged() + // exclude dodgy params + .Where(parameters => parameters is { StartIndex: >= 0, Size: > 0 }) + .Select(request => + { + virtualParams = request; + + // have not received the comparer yet + if (applicator is null) return Empty; + + // re-apply virtual changes + return ApplyVirtualChanges(); + }); + + 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 ApplyVirtualChanges(changes); + }); + + return + comparerChanged + .Merge(paramsChanged) + .Merge(dataChange) + .Where(changes => changes.Count is not 0) + .SubscribeSafe(observer); + + ChangeSet> ApplyVirtualChanges(IChangeSet? changeSet = null) + { + var previousVirtualList = virtualItems; + + // re-calculate virtual changes + var currentVirtualItems = new List>(virtualParams.Size); + currentVirtualItems.AddRange(sortedList.Skip(virtualParams.StartIndex).Take(virtualParams.Size)); + + var responseParams = new VirtualResponse(virtualParams.Size, virtualParams.StartIndex, sortedList.Count); + var context = new VirtualContext(responseParams, comparer!, _options); + + // calculate notifications + var virtualChanges = CalculateVirtualChanges(context, currentVirtualItems, previousVirtualList, changeSet); + + virtualItems = currentVirtualItems; + + return virtualChanges; + } + }); + + // Calculates any changes within the virtualized range. + private static ChangeSet> CalculateVirtualChanges(VirtualContext 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/Internal/SortExtensions.cs b/src/DynamicData/Cache/Internal/SortExtensions.cs new file mode 100644 index 000000000..89f16cf75 --- /dev/null +++ b/src/DynamicData/Cache/Internal/SortExtensions.cs @@ -0,0 +1,54 @@ +// 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. + +namespace DynamicData.Cache.Internal; + +internal static class SortExtensions +{ + public static int GetCurrentPosition(this IList source, TItem item, IComparer comparer, bool useBinarySearch = false) + { + var index = useBinarySearch ? source.BinarySearch(item, comparer) : source.IndexOf(item); + + if (index < 0) + { + throw new SortException($"Cannot find item: {typeof(TItem).Name} -> {item} from {source.Count} items"); + } + + return index; + } + + public static int GetInsertPosition(this IList source, T item, IComparer comparer, bool useBinarySearch = false) + { + return useBinarySearch + ? source.GetInsertPositionBinary(item, comparer) + : source.GetInsertPositionLinear(item, comparer); + } + + public static int GetInsertPositionBinary(this IList list, TItem t, IComparer c) + { + var index = list.BinarySearch(t, c); + var insertIndex = ~index; + + // sort is not returning uniqueness + if (insertIndex < 0) + { + throw new SortException("Binary search has been specified, yet the sort does not yield uniqueness"); + } + + return insertIndex; + } + + public static int GetInsertPositionLinear(this IList list, TItem t, IComparer c) + { + for (var i = 0; i < list.Count; i++) + { + if (c.Compare(t, list[i]) < 0) + { + return i; + } + } + + return list.Count; + } +} diff --git a/src/DynamicData/Cache/Internal/SortedKeyValueApplicator.cs b/src/DynamicData/Cache/Internal/SortedKeyValueApplicator.cs new file mode 100644 index 000000000..e9908e192 --- /dev/null +++ b/src/DynamicData/Cache/Internal/SortedKeyValueApplicator.cs @@ -0,0 +1,139 @@ +// 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.Cache.Internal; + +/* + * Object which maintains a sorted list of key value pair and produces a change set. + * + * Used by virtualise and page. + */ +internal sealed class SortedKeyValueApplicator + where TObject : notnull + where TKey : notnull +{ + private readonly Cache _cache = new(); + private readonly List> _target; + private readonly SortAndBindOptions _options; + + private KeyValueComparer _comparer; + + public SortedKeyValueApplicator(List> target, + KeyValueComparer comparer, + SortAndBindOptions options) + { + _target = target; + _options = options; + _comparer = comparer; + } + + public void ChangeComparer(KeyValueComparer comparer) + { + _comparer = comparer; + + _target.Sort(comparer); + } + + public void ProcessChanges(IChangeSet changes) + { + _cache.Clone(changes); + + var fireReset = _options.ResetThreshold > 0 && _options.ResetThreshold < changes.Count; + + if (fireReset) + { + Reset(); + } + else + { + ApplyChanges(changes); + } + } + + public void Reset() + { + var sorted = _cache.KeyValues.OrderBy(t => t, _comparer); + _target.Clear(); + _target.AddRange(sorted); + } + + private void ApplyChanges(IChangeSet changes) + { + // iterate through collection, find sorted position and apply changes + foreach (var change in changes.ToConcreteType()) + { + var item = new KeyValuePair(change.Key, change.Current); + + switch (change.Reason) + { + case ChangeReason.Add: + { + var index = GetInsertPosition(item); + _target.Insert(index, item); + } + break; + case ChangeReason.Update: + { + var previous = new KeyValuePair(change.Key, change.Previous.Value); + var currentIndex = GetCurrentPosition(previous); + var updatedIndex = GetInsertPosition(item); + + // We need to recalibrate as GetCurrentPosition includes the current item + updatedIndex = currentIndex < updatedIndex ? updatedIndex - 1 : updatedIndex; + + // Some control suites and platforms do not support replace, whiles others do, so we opt in. + if (_options.UseReplaceForUpdates && currentIndex == updatedIndex) + { + _target[currentIndex] = item; + } + else + { + _target.RemoveAt(currentIndex); + _target.Insert(updatedIndex, item); + } + } + break; + case ChangeReason.Remove: + { + var currentIndex = GetCurrentPosition(item); + _target.RemoveAt(currentIndex); + } + break; + case ChangeReason.Refresh: + { + /* look up current location, and new location + * + * Use the linear methods as binary search does not work if we do not have an already sorted list. + * Otherwise, SortAndBindWithBinarySearch.Refresh() unit test will break. + * + * If consumers are using BinarySearch and a refresh event is sent here, they probably should exclude refresh + * events with .WhereReasonsAreNot(ChangeReason.Refresh), but it may be problematic to exclude refresh automatically + * as that would effectively be swallowing an error. + */ + var currentIndex = _target.IndexOf(item); + var updatedIndex = _target.GetInsertPositionLinear(item, _comparer); + + // We need to recalibrate as GetInsertPosition includes the current item + updatedIndex = currentIndex < updatedIndex ? updatedIndex - 1 : updatedIndex; + if (updatedIndex != currentIndex) + { + _target.RemoveAt(currentIndex); + _target.Insert(updatedIndex, item); + } + } + break; + case ChangeReason.Moved: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private int GetCurrentPosition(KeyValuePair item) => _target.GetCurrentPosition(item, _comparer, _options.UseBinarySearch); + + private int GetInsertPosition(KeyValuePair item) => _target.GetInsertPosition(item, _comparer, _options.UseBinarySearch); +} diff --git a/src/DynamicData/Cache/Internal/UniquenessEnforcer.cs b/src/DynamicData/Cache/Internal/UniquenessEnforcer.cs index 53bf65ac5..45900c096 100644 --- a/src/DynamicData/Cache/Internal/UniquenessEnforcer.cs +++ b/src/DynamicData/Cache/Internal/UniquenessEnforcer.cs @@ -11,20 +11,37 @@ internal sealed class UniquenessEnforcer(IObservable> Run() => - /* -* If we handle refreshes, we cannot use .Last() as the last in the groupd may be a refresh, -* and a previous in the group may add or update. Suddenly this scenario becomes very complicated -* so for this phase we'll ignore these. -* +/* + For refresh, we need to check whether there was a previous add or update in the batch. If not use refresh, + otherwise use the previous update. */ source - .WhereReasonsAreNot(ChangeReason.Refresh, ChangeReason.Moved) + .WhereReasonsAreNot(ChangeReason.Moved) .Scan( - new ChangeAwareCache(), + (ChangeAwareCache?)null, (cache, changes) => { - var grouped = changes.GroupBy(c => c.Key).Select(c => c.Last()); + cache ??= new ChangeAwareCache(changes.Count); + + var grouped = changes.GroupBy(c => c.Key).Select(c => + { + var all = c.ToArray(); + + if (all.Length > 1) + { + /* Extreme edge case where compound has mixture of changes ending in refresh */ + // find the previous non-refresh and return if found + for (var i = all.Length - 1; i >= 0; i--) + { + var candidate = all[i]; + if (candidate.Reason != ChangeReason.Refresh) + return candidate; + } + } + // the entire batch are all refresh events + return all[0]; + }); foreach (var change in grouped) { @@ -37,10 +54,13 @@ public IObservable> Run() => case ChangeReason.Remove: cache.Remove(change.Key); break; + case ChangeReason.Refresh: + cache.Refresh(change.Key); + break; } } return cache; }) - .Select(state => state.CaptureChanges()); + .Select(state => state!.CaptureChanges()); } diff --git a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs index 2becd5c25..b73095c8f 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Collections.ObjectModel; +using System.Diagnostics; using DynamicData.Binding; namespace DynamicData; @@ -12,6 +13,80 @@ namespace DynamicData; /// public static partial class ObservableCacheEx { + /// + /// Bind virtualized and sorted 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> SortAndBind( + this IObservable>> source, + out ReadOnlyObservableCollection readOnlyObservableCollection) + where TObject : notnull + where TKey : notnull + { + var targetList = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(targetList); + + return source.SortAndBind(targetList); + } + + /// + /// Bind virtualized 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> SortAndBind( + this IObservable>> source, + out ReadOnlyObservableCollection readOnlyObservableCollection, + SortAndBindOptions options) + where TObject : notnull + where TKey : notnull + { + var targetList = new ObservableCollectionExtended(); + readOnlyObservableCollection = new ReadOnlyObservableCollection(targetList); + + return source.SortAndBind(targetList, options); + } + + /// + /// Bind virtualized 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> SortAndBind( + this IObservable>> source, + IList targetList) + where TObject : notnull + where TKey : notnull => + new SortAndBindVirtualized(source, targetList, null).Run(); + + /// + /// Bind virtualized 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> SortAndBind( + this IObservable>> source, + IList targetList, + SortAndBindOptions options) + where TObject : notnull + where TKey : notnull => + new SortAndBindVirtualized(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 new file mode 100644 index 000000000..a9246b4c2 --- /dev/null +++ b/src/DynamicData/Cache/ObservableCacheEx.VirtualiseAndPage.cs @@ -0,0 +1,191 @@ +// 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.Cache.Internal; + +namespace DynamicData; + +/// +/// ObservableCache extensions for the virtualised group of operators. +/// +public static partial class ObservableCacheEx +{ + /// + /// Sort and virtualize 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>> SortAndVirtualize(this IObservable> source, + IComparer comparer, + IObservable virtualRequests) + where TObject : notnull + where TKey : notnull => + source.SortAndVirtualize(comparer, virtualRequests, new SortAndVirtualizeOptions()); + + /// + /// Sort and virtualize 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>> SortAndVirtualize( + this IObservable> source, + IObservable> comparerChanged, + IObservable virtualRequests) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + virtualRequests.ThrowArgumentNullExceptionIfNull(nameof(virtualRequests)); + + return source.SortAndVirtualize(comparerChanged, virtualRequests, new SortAndVirtualizeOptions()); + } + + /// + /// Sort and virtualize 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>> SortAndVirtualize( + this IObservable> source, + IComparer comparer, + IObservable virtualRequests, + SortAndVirtualizeOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + virtualRequests.ThrowArgumentNullExceptionIfNull(nameof(virtualRequests)); + + return new SortAndVirtualize(source, comparer, virtualRequests, options).Run(); + } + + /// + /// Sort and virtualize 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>> SortAndVirtualize( + this IObservable> source, + IObservable> comparerChanged, + IObservable virtualRequests, + SortAndVirtualizeOptions options) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + virtualRequests.ThrowArgumentNullExceptionIfNull(nameof(virtualRequests)); + + return new SortAndVirtualize(source, comparerChanged, virtualRequests, options).Run(); + } + + /// + /// Virtualises the underlying data from the specified source. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The virtualising requests. + /// An observable which will emit virtual change sets. + /// source. + public static IObservable> Virtualise(this IObservable> source, IObservable virtualRequests) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + virtualRequests.ThrowArgumentNullExceptionIfNull(nameof(virtualRequests)); + + return new Virtualise(source, virtualRequests).Run(); + } + + /// + /// Limits the size of the result set to the specified number. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// 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) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + + 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(); + } + + /// + /// 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, 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 source.Sort(comparer).Top(size); + } + + /// + /// Returns the page as specified by the pageRequests observable. + /// + /// The type of the object. + /// The type of the key. + /// The source. + /// The page requests. + /// An observable which emits change sets. + public static IObservable> Page(this IObservable> source, IObservable pageRequests) + where TObject : notnull + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + pageRequests.ThrowArgumentNullExceptionIfNull(nameof(pageRequests)); + + return new Page(source, pageRequests).Run(); + } +} diff --git a/src/DynamicData/Cache/ObservableCacheEx.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index 8363d1f94..fc950e3f5 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -3592,24 +3592,6 @@ public static IObservable> Or(this IObs return sources.Combine(CombineOperator.Or); } - /// - /// Returns the page as specified by the pageRequests observable. - /// - /// The type of the object. - /// The type of the key. - /// The source. - /// The page requests. - /// An observable which emits change sets. - public static IObservable> Page(this IObservable> source, IObservable pageRequests) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - pageRequests.ThrowArgumentNullExceptionIfNull(nameof(pageRequests)); - - return new Page(source, pageRequests).Run(); - } - /// /// Populate a cache from an observable stream. /// @@ -4588,56 +4570,6 @@ public static IObservable> ToObservableOptional return source.ToObservableOptional(key, equalityComparer); } - /// - /// Limits the size of the result set to the specified number. - /// - /// The type of the object. - /// The type of the key. - /// The source. - /// 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) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - - 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(); - } - - /// - /// 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, 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 source.Sort(comparer).Top(size); - } - /// /// Converts the change set into a fully formed sorted collection. Each change in the source results in a new sorted collection. /// @@ -6151,25 +6083,6 @@ public static IObservable> UpdateIndex source.Do(changes => changes.SortedItems.Select((update, index) => new { update, index }).ForEach(u => u.update.Value.Index = u.index)); - /// - /// Virtualises the underlying data from the specified source. - /// - /// The type of the object. - /// The type of the key. - /// The source. - /// The virirtualising requests. - /// An observable which will emit virtual change sets. - /// source. - public static IObservable> Virtualise(this IObservable> source, IObservable virtualRequests) - where TObject : notnull - where TKey : notnull - { - source.ThrowArgumentNullExceptionIfNull(nameof(source)); - virtualRequests.ThrowArgumentNullExceptionIfNull(nameof(virtualRequests)); - - return new Virtualise(source, virtualRequests).Run(); - } - /// /// Returns an observable of any updates which match the specified key, proceeded with the initial cache state. /// diff --git a/src/DynamicData/Cache/SortAndVirtualizeOptions.cs b/src/DynamicData/Cache/SortAndVirtualizeOptions.cs new file mode 100644 index 000000000..36f272424 --- /dev/null +++ b/src/DynamicData/Cache/SortAndVirtualizeOptions.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 SortAndVirtualizeOptions() +{ + /// + /// 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/Cache/Tests/ChangeSetAggregator.cs b/src/DynamicData/Cache/Tests/ChangeSetAggregator.cs index 0f7d34059..bb07670ff 100644 --- a/src/DynamicData/Cache/Tests/ChangeSetAggregator.cs +++ b/src/DynamicData/Cache/Tests/ChangeSetAggregator.cs @@ -7,7 +7,6 @@ using DynamicData.Diagnostics; -// ReSharper disable once CheckNamespace namespace DynamicData.Tests; /// @@ -15,19 +14,18 @@ namespace DynamicData.Tests; /// /// The type of the object. /// The type of the key. -public class ChangeSetAggregator : IDisposable +/// The type of context. +public sealed class ChangeSetAggregator : IDisposable where TObject : notnull where TKey : notnull { private readonly IDisposable _disposer; - private bool _isDisposed; - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The source. - public ChangeSetAggregator(IObservable> source) + public ChangeSetAggregator(IObservable> source) { var published = source.Publish(); @@ -77,7 +75,7 @@ public ChangeSetAggregator(IObservable> source) /// /// The messages. /// - public IList> Messages { get; } = new List>(); + public IList> Messages { get; } = new List>(); /// /// Gets the summary. @@ -90,28 +88,86 @@ public ChangeSetAggregator(IObservable> source) /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + public void Dispose() => _disposer.Dispose(); +} + +/// +/// Aggregates all events and statistics for a change set to help assertions when testing. +/// +/// The type of the object. +/// The type of the key. +public sealed class ChangeSetAggregator : IDisposable + where TObject : notnull + where TKey : notnull +{ + private readonly IDisposable _disposer; /// - /// Disposes of managed and unmanaged responses. + /// Initializes a new instance of the class. /// - /// If being called by the Dispose method. - protected virtual void Dispose(bool isDisposing) + /// The source. + public ChangeSetAggregator(IObservable> source) { - if (_isDisposed) - { - return; - } + var published = source.Publish(); + + Data = published.AsObservableCache(); - _isDisposed = true; + var results = published.Subscribe(updates => Messages.Add(updates), ex => Error = ex, () => IsCompleted = true); + var summariser = published.CollectUpdateStats().Subscribe(summary => Summary = summary, _ => { }); + var connected = published.Connect(); - if (isDisposing) - { - _disposer.Dispose(); - } + _disposer = Disposable.Create( + () => + { + Data.Dispose(); + connected.Dispose(); + summariser.Dispose(); + results.Dispose(); + }); } + + /// + /// Gets the data. + /// + /// + /// The data. + /// + public IObservableCache Data { get; } + + /// + /// Gets the error. + /// + /// + /// The error. + /// + public Exception? Error { get; private set; } + + /// + /// Gets a value indicating whether or not the ChangeSet fired OnCompleted. + /// + /// + /// Boolean Value. + /// + public bool IsCompleted { get; private set; } + + /// + /// Gets the messages. + /// + /// + /// The messages. + /// + public IList> Messages { get; } = new List>(); + + /// + /// Gets the summary. + /// + /// + /// The summary. + /// + public ChangeSummary Summary { get; private set; } = ChangeSummary.Empty; + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() => _disposer.Dispose(); } diff --git a/src/DynamicData/Cache/Tests/TestEx.cs b/src/DynamicData/Cache/Tests/TestEx.cs index e3581b14d..0feb06ef8 100644 --- a/src/DynamicData/Cache/Tests/TestEx.cs +++ b/src/DynamicData/Cache/Tests/TestEx.cs @@ -21,6 +21,18 @@ public static ChangeSetAggregator AsAggregator(thi where TObject : notnull where TKey : notnull => new(source); + /// + /// Aggregates all events and statistics for a paged change set to help assertions when testing. + /// + /// The type of the object. + /// The type of the key. + /// The type of context. + /// The source. + /// The change set aggregator. + public static ChangeSetAggregator AsAggregator(this IObservable> source) + where TObject : notnull + where TKey : notnull => new(source); + /// /// Aggregates all events and statistics for a distinct change set to help assertions when testing. /// diff --git a/src/DynamicData/Cache/VirtualContext.cs b/src/DynamicData/Cache/VirtualContext.cs new file mode 100644 index 000000000..6c5ecfe25 --- /dev/null +++ b/src/DynamicData/Cache/VirtualContext.cs @@ -0,0 +1,25 @@ +// 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. + +namespace DynamicData; + +/// +/// Parameters associated with the virtualize operation. +/// +/// The type of object. +/// Response parameters. +/// The comparer used to order the items. +/// The options used to perform virtualization. +public record VirtualContext( + IVirtualResponse Response, + IComparer Comparer, + SortAndVirtualizeOptions Options) +{ + internal static readonly VirtualContext Empty = new + ( + new VirtualResponse(0, 0, 0), + Comparer.Default, + new SortAndVirtualizeOptions() + ); +} diff --git a/src/DynamicData/DynamicData.csproj b/src/DynamicData/DynamicData.csproj index 6da9b0cd9..3422b9917 100644 --- a/src/DynamicData/DynamicData.csproj +++ b/src/DynamicData/DynamicData.csproj @@ -22,7 +22,7 @@ Dynamic Data is a comprehensive caching and data manipulation solution which int - + ObservableCacheEx.cs