Skip to content

Commit 35cb45b

Browse files
authored
Merge pull request #179 from ChrisPulman/AddQuaternaries
Add unit tests for new reactive list features
2 parents 30ea8ea + 8c74dad commit 35cb45b

14 files changed

+2412
-292
lines changed

README.md

Lines changed: 189 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![License](https://img.shields.io/github/license/ChrisPulman/ReactiveList.svg?style=flat-square)](LICENSE)
66
[![Build Status](https://img.shields.io/github/actions/workflow/status/ChrisPulman/ReactiveList/BuildOnly.yml?branch=main&style=flat-square)](https://github.com/ChrisPulman/ReactiveList/actions)
77

8-
A lightweight, high-performance reactive list with fine-grained change tracking built on [DynamicData](https://github.com/reactivemarbles/DynamicData) and [System.Reactive](https://github.com/dotnet/reactive).
8+
A lightweight, high-performance reactive list with fine-grained change tracking built on [System.Reactive](https://github.com/dotnet/reactive).
99

1010
**Targets:** .NET Standard 2.0 | .NET 8 | .NET 9 | .NET 10
1111

@@ -65,6 +65,15 @@ Install-Package ReactiveList
6565

6666
## Quick Start
6767

68+
This library provides:
69+
70+
- A `ReactiveList<T>` implementation that allows you to observe changes in real-time.
71+
- A `Reactive2DList<T>` for managing a list of lists (2D structure) with reactive capabilities.
72+
- A `QuaternaryDictionary<TKey, TValue>` for high-performance key-value storage with reactive features.
73+
- A `QuaternaryList<T>` for optimized list operations at scale with reactive capabilities.
74+
75+
Here's a quick example to get you started:
76+
6877
```csharp
6978
using CP.Reactive;
7079

@@ -750,38 +759,6 @@ Inherits from `ReactiveList<ReactiveList<T>>` and adds:
750759

751760
- **Disposal**: Always dispose `ReactiveList` instances to clean up subscriptions and internal resources.
752761

753-
754-
---
755-
756-
## QuaternaryList VS DynamicData SourceCache - Benchmark Results
757-
758-
### Performance Comparison (Mean Time in nanoseconds)
759-
760-
| Operation | Count | Before (ns) | After (ns) | Improvement |
761-
|-----------|-------|-------------|------------|-------------|
762-
| AddRange | 100 | 60,227 | 66,080 | ~same |
763-
| AddRange | 1,000 | 439,706 | 77,779 | **5.6x faster** |
764-
| AddRange | 10,000 | 3,246,674 | 98,279 | **33x faster** |
765-
| RemoveRange | 100 | 60,217 | 67,212 | ~same |
766-
| RemoveRange | 1,000 | 643,564 | 91,937 | **7x faster** |
767-
| RemoveRange | 10,000 | 5,734,650 | 1,292,425 | **4.4x faster** |
768-
| Clear | 100 | 59,980 | 66,179 | ~same |
769-
| Clear | 1,000 | 443,622 | 76,897 | **5.8x faster** |
770-
| Clear | 10,000 | 3,720,656 | 98,732 | **37x faster** |
771-
| Contains | 1,000 | 443,302 | 76,718 | **5.8x faster** |
772-
| Contains | 10,000 | 3,979,264 | 98,848 | **40x faster** |
773-
774-
### Memory Allocation Comparison (bytes)
775-
776-
| Operation | Count | Before (bytes) | After (bytes) | Improvement |
777-
|-----------|-------|----------------|---------------|-------------|
778-
| AddRange | 1,000 | 29,424 | 10,676 | **2.75x less** |
779-
| AddRange | 10,000 | 337,449 | 46,949 | **7.2x less** |
780-
| RemoveRange | 1,000 | 36,813 | 12,783 | **2.9x less** |
781-
| RemoveRange | 10,000 | 406,655 | 49,535 | **8.2x less** |
782-
| Clear | 1,000 | 29,452 | 10,676 | **2.76x less** |
783-
| Clear | 10,000 | 337,472 | 46,958 | **7.2x less** |
784-
785762
---
786763

787764
## QuaternaryDictionary vs SourceCache Benchmark Results
@@ -838,8 +815,186 @@ Inherits from `ReactiveList<ReactiveList<T>>` and adds:
838815

839816
---
840817

818+
## QuaternaryDictionary and QuaternaryList
819+
820+
High-performance, low-allocation key-value and list collections optimized for large-scale reactive applications.
821+
Performance benchmarks show significant advantages over traditional collections in batch operations and memory usage.
822+
This makes them ideal for scenarios involving large datasets, frequent updates, and real-time data processing.
823+
824+
The Stream property exposes an observable sequence of changes, enabling reactive programming patterns.
825+
826+
Example usage in an Address Book application:
827+
828+
```csharp
829+
using System.Collections.ObjectModel;
830+
using System.Reactive.Linq;
831+
using System.Reactive.Subjects;
832+
using CP.Reactive;
833+
using ReactiveUI; // For RxApp.MainThreadScheduler
834+
835+
public class AddressBookViewModel : IDisposable
836+
{
837+
private readonly QuaternaryList<Contact> _contactList = [];
838+
private readonly QuaternaryDictionary<Guid, Contact> _contactMap = [];
839+
private readonly BehaviorSubject<string> _searchText = new(string.Empty);
840+
private bool _disposedValue;
841+
842+
public AddressBookViewModel()
843+
{
844+
InitializeIndices();
845+
InitializePipelines();
846+
}
847+
848+
public ReadOnlyObservableCollection<Contact> AllContacts { get; private set; }
849+
850+
public ReadOnlyObservableCollection<Contact> FavoriteContacts { get; private set; }
851+
852+
public ReadOnlyObservableCollection<Contact> NewYorkContacts { get; private set; }
853+
854+
public ReadOnlyObservableCollection<Contact> SearchResults { get; private set; } // Dynamic
855+
856+
public string SearchQuery
857+
{
858+
get => _searchText.Value;
859+
set => _searchText.OnNext(value ?? string.Empty);
860+
}
861+
862+
public void BulkImport(int count)
863+
{
864+
var newContacts = Enumerable.Range(0, count).Select(i =>
865+
new Contact(
866+
Guid.NewGuid(),
867+
$"User{i}",
868+
$"Smith{i}",
869+
$"user{i}@company.com",
870+
i % 2 == 0 ? "Engineering" : "HR",
871+
i % 10 == 0, // 10% are favorites
872+
new Address("123 Main", i % 5 == 0 ? "New York" : "London", "10001", "USA"))).ToList();
873+
874+
// High-Speed Parallel Add
875+
_contactList.AddRange(newContacts);
876+
_contactMap.AddRange(newContacts.Select(c => new KeyValuePair<Guid, Contact>(c.Id, c)));
877+
}
878+
879+
public void BulkRemoveInactive()
880+
{
881+
// Query utilizing Secondary Index for speed
882+
var hrDept = _contactList.Query("ByDepartment", "HR").ToList();
883+
884+
// Bulk Thread-Safe Remove
885+
_contactList.RemoveRange(hrDept);
886+
887+
// Sync Dictionary
888+
foreach (var c in hrDept)
889+
{
890+
_contactMap.Remove(c.Id);
891+
}
892+
}
893+
894+
public void UpdateCityName(string oldCity, string newCity)
895+
{
896+
// 1. Find targets using Index (Fast)
897+
var targets = _contactList.Query("ByCity", oldCity).ToList();
898+
899+
// 2. Modify and Update
900+
// Since Records are immutable, we replace the object
901+
var updates = new List<Contact>();
902+
foreach (var c in targets)
903+
{
904+
updates.Add(c with { HomeAddress = c.HomeAddress with { City = newCity } });
905+
}
906+
907+
// 3. Apply updates (Remove old, Add new) effectively performs an update
908+
_contactList.RemoveRange(targets);
909+
_contactList.AddRange(updates);
910+
}
911+
912+
public void Dispose()
913+
{
914+
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
915+
Dispose(disposing: true);
916+
GC.SuppressFinalize(this);
917+
}
918+
919+
protected virtual void Dispose(bool disposing)
920+
{
921+
if (!_disposedValue)
922+
{
923+
if (disposing)
924+
{
925+
_contactList.Dispose();
926+
_contactMap.Dispose();
927+
_searchText.Dispose();
928+
}
929+
930+
_disposedValue = true;
931+
}
932+
}
933+
934+
private static bool Matches(Contact? c, string query)
935+
{
936+
if (c == null)
937+
{
938+
return false;
939+
}
940+
941+
if (string.IsNullOrWhiteSpace(query))
942+
{
943+
return true;
944+
}
945+
946+
return c.LastName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
947+
c.Email.Contains(query, StringComparison.OrdinalIgnoreCase);
948+
}
949+
950+
private void InitializeIndices()
951+
{
952+
// Add High-Speed Lookup Indices (O(1) access)
953+
_contactList.AddIndex("ByCity", c => c.HomeAddress.City);
954+
_contactList.AddIndex("ByDepartment", c => c.Department);
955+
956+
// Map Dictionary for ID-based updates
957+
_contactMap.AddValueIndex("ByEmail", c => c.Email);
958+
}
959+
960+
private void InitializePipelines()
961+
{
962+
// 1. ALL CONTACTS (Throttled 100ms)
963+
_contactList.CreateView(c => true, RxApp.MainThreadScheduler, throttleMs: 100)
964+
.ToProperty(x => AllContacts = x);
965+
966+
// 2. FAVORITES (Filtered Subset)
967+
_contactList.CreateView(c => c.IsFavorite, RxApp.MainThreadScheduler, throttleMs: 100)
968+
.ToProperty(x => FavoriteContacts = x);
969+
970+
// 3. SECONDARY KEY SUBSET (City == "New York")
971+
// Uses the Stream for updates, but efficient logic for the filter
972+
_contactList.CreateView(c => c.HomeAddress.City == "New York", RxApp.MainThreadScheduler, throttleMs: 200)
973+
.ToProperty(x => NewYorkContacts = x);
974+
975+
// 4. DYNAMIC SEARCH QUERY (Complex Pipeline)
976+
// Combines the Cache Stream + Search Text Stream
977+
var searchPipeline = _contactList.Stream
978+
.CombineLatest(_searchText, (change, query) => new { change, query })
979+
.Where(x => Matches(x.change.Item, x.query))
980+
.Select(x => x.change); // Project back to notification
981+
982+
// Note: For a true search view, we usually rebuild the collection when query changes.
983+
// This simulates a "Live Search Result" stream.
984+
new ReactiveView<Contact>(
985+
searchPipeline,
986+
[.. _contactList], // Initial Snapshot
987+
c => Matches(c, _searchText.Value),
988+
TimeSpan.FromMilliseconds(50),
989+
RxApp.MainThreadScheduler)
990+
.ToProperty(x => SearchResults = x);
991+
}
992+
}
993+
```
994+
995+
---
996+
841997
**Dependencies:**
842-
- [DynamicData](https://github.com/reactivemarbles/DynamicData)
843998
- [System.Reactive](https://github.com/dotnet/reactive)
844999

8451000
---
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) Chris Pulman. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#if NET6_0_OR_GREATER
5+
using System;
6+
using CP.Reactive;
7+
using FluentAssertions;
8+
using Xunit;
9+
10+
namespace ReactiveList.Test;
11+
12+
/// <summary>
13+
/// Tests for CacheAction enum.
14+
/// </summary>
15+
public class CacheActionTests
16+
{
17+
/// <summary>
18+
/// CacheAction should have correct values.
19+
/// </summary>
20+
[Fact]
21+
public void CacheAction_ShouldHaveCorrectValues()
22+
{
23+
((int)CacheAction.Added).Should().Be(0);
24+
((int)CacheAction.Removed).Should().Be(1);
25+
((int)CacheAction.Updated).Should().Be(2);
26+
((int)CacheAction.Cleared).Should().Be(3);
27+
((int)CacheAction.BatchOperation).Should().Be(4);
28+
}
29+
30+
/// <summary>
31+
/// All CacheAction values should be defined.
32+
/// </summary>
33+
[Fact]
34+
public void CacheAction_AllValuesShouldBeDefined()
35+
{
36+
var values = Enum.GetValues<CacheAction>();
37+
38+
values.Should().HaveCount(5);
39+
values.Should().Contain(CacheAction.Added);
40+
values.Should().Contain(CacheAction.Removed);
41+
values.Should().Contain(CacheAction.Updated);
42+
values.Should().Contain(CacheAction.Cleared);
43+
values.Should().Contain(CacheAction.BatchOperation);
44+
}
45+
}
46+
#endif

0 commit comments

Comments
 (0)