Table of Contents

ListFeedExtensions Class

LINQ-like operations for list feeds with incremental change tracking.

Namespace: DevBitsLab.Feeds
Assembly: DevBitsLab.Feeds.dll

public static class ListFeedExtensions

Overview

Unlike standard LINQ, these extensions create reactive list feeds that:

  • Subscribe to source ItemsChanged events
  • Propagate changes incrementally when possible
  • Maintain their own change notifications

Operations

Method Description
Select Project items
Where Filter items
OrderBy Sort ascending
OrderByDescending Sort descending
Take First N items
Skip Skip N items
Distinct Remove duplicates
Aggregate Compute aggregate value
AsFeed Convert to Feed<IReadOnlyList<T>>

Chaining Example

var products = ListFeed<Product>.Create(LoadProductsAsync);

// Create a filtered, sorted, projected view
var activeProductNames = products
    .Where(p => p.IsActive)
    .OrderBy(p => p.Name)
    .Select(p => p.Name);

// Updates incrementally when products changes

Select

Project each element into a new form.

public static ListFeed<TResult> Select<TSource, TResult>(
    this IListFeed<TSource> source,
    Func<TSource, TResult> selector)

public static ListFeed<TResult> Select<TSource, TResult>(
    this IListFeed<TSource> source,
    Func<TSource, int, TResult> selector)  // With index
Source Change Result Change
Add Add projected item
Remove Remove item
Update Re-project, update
Move Move (no re-project)

Example:

var customerFeed = ListFeed<Customer>.Create(LoadCustomersAsync);

// Project to view models
var viewModels = customerFeed.Select(c => new CustomerViewModel(c));

// Project to display strings
var displayNames = customerFeed.Select(c => $"{c.FirstName} {c.LastName}");

// With index (for numbered lists)
var numbered = customerFeed.Select((c, i) => $"{i + 1}. {c.Name}");

Note: Index-based selector rebuilds on structural changes (add/remove/move).


Where

Filter items by predicate.

public static ListFeed<T> Where<T>(
    this IListFeed<T> source,
    Func<T, bool> predicate)
Source Change Result Behavior
Add (matches) Add to result
Add (no match) Ignored
Remove (was included) Remove from result
Update Add/remove/update based on match

Example:

var orders = ListFeed<Order>.Create(LoadOrdersAsync);

// Active orders only
var activeOrders = orders.Where(o => o.Status == OrderStatus.Active);

// High-value orders
var highValue = orders.Where(o => o.Total > 1000);

// Complex condition
var urgentPending = orders.Where(o => 
    o.Status == OrderStatus.Pending && 
    o.DueDate < DateTime.Today.AddDays(3));

OrderBy

Sort items in ascending order.

public static ListFeed<T> OrderBy<T, TKey>(
    this IListFeed<T> source,
    Func<T, TKey> keySelector)
Source Change Result Behavior
Add Insert at sorted position
Remove Remove from result
Update Reposition if key changed
Move Ignored (sorted order prevails)

Example:

var employees = ListFeed<Employee>.Create(LoadEmployeesAsync);

// Sort by name
var byName = employees.OrderBy(e => e.LastName);

// Sort by date
var byHireDate = employees.OrderBy(e => e.HireDate);

OrderByDescending

Sort items in descending order.

public static ListFeed<T> OrderByDescending<T, TKey>(
    this IListFeed<T> source,
    Func<T, TKey> keySelector)

Example:

var sales = ListFeed<Sale>.Create(LoadSalesAsync);

// Highest sales first
var topSales = sales.OrderByDescending(s => s.Amount);

// Most recent first
var recentFirst = sales.OrderByDescending(s => s.Date);

Take

Return first N items.

public static ListFeed<T> Take<T>(
    this IListFeed<T> source, 
    int count)

Example:

var notifications = ListFeed<Notification>.Create(LoadNotificationsAsync);

// Show only first 5
var top5 = notifications.Take(5);

// Top 10 sales (combine with sort)
var top10Sales = sales
    .OrderByDescending(s => s.Amount)
    .Take(10);

Skip

Bypass first N items.

public static ListFeed<T> Skip<T>(
    this IListFeed<T> source, 
    int count)

Example:

var results = ListFeed<SearchResult>.Create(SearchAsync);

// Pagination: page 2 (items 21-40)
var page2 = results.Skip(20).Take(20);

// Skip header row
var dataRows = csvRows.Skip(1);

Distinct

Remove duplicate items.

public static ListFeed<T> Distinct<T>(
    this IListFeed<T> source)

public static ListFeed<T> Distinct<T>(
    this IListFeed<T> source, 
    IEqualityComparer<T> comparer)

Example:

var tags = ListFeed<string>.Create(LoadAllTagsAsync);

// Remove duplicates
var uniqueTags = tags.Distinct();

// Case-insensitive distinct
var uniqueIgnoreCase = tags.Distinct(StringComparer.OrdinalIgnoreCase);

Aggregate

Compute a value from the list.

Standard Aggregate

Updates on state changes (Reset/Refresh only).

public static Feed<TResult> Aggregate<TSource, TResult>(
    this IListFeed<TSource> source,
    Func<IReadOnlyList<TSource>, TResult> aggregator)

Example:

var orders = ListFeed<Order>.Create(LoadOrdersAsync);

// Total value
var totalFeed = orders.Aggregate(list => list.Sum(o => o.Total));

// Count
var countFeed = orders.Aggregate(list => list.Count);

// Average
var avgFeed = orders.Aggregate(list => 
    list.Count > 0 ? list.Average(o => o.Total) : 0);

AggregateWithItemChanges

Updates on every item change.

public static Feed<TResult> AggregateWithItemChanges<TSource, TResult>(
    this IListFeed<TSource> source,
    Func<IReadOnlyList<TSource>, TResult> aggregator)

Example:

var cart = ListFeed<CartItem>.Empty();

// Live cart total - updates on every add/remove
var cartTotal = cart.AggregateWithItemChanges(items => 
    items.Sum(i => i.Price * i.Quantity));

Warning: May impact performance with large lists or frequent updates.


AsFeed

Convert to Feed<IReadOnlyList<T>>.

public static Feed<IReadOnlyList<T>> AsFeed<T>(
    this IListFeed<T> source)

Use case: Combining with other feeds.

var products = ListFeed<Product>.Create(LoadProductsAsync);
var category = Feed<Category>.Create(LoadCategoryAsync);

// Need Feed<T> for CombineWith
var combined = products.AsFeed().CombineWith(
    category,
    (productList, cat) => new CategoryProductsViewModel(cat, productList));

Common Patterns

Filtered & Sorted View

var products = ListFeed<Product>.Create(LoadProductsAsync);

var displayProducts = products
    .Where(p => p.IsAvailable)
    .OrderBy(p => p.Category)
    .ThenBy(p => p.Name);  // Note: ThenBy not available, use composite key

// Alternative: composite key for multi-sort
var sorted = products
    .OrderBy(p => (p.Category, p.Name));

Pagination

const int pageSize = 20;
int currentPage = 0;

var allItems = ListFeed<Item>.Create(LoadAllItemsAsync);
var currentPageItems = allItems
    .Skip(currentPage * pageSize)
    .Take(pageSize);

void NextPage() {
    currentPage++;
    // Need to recreate derived feed with new page
}

Search Results

var allProducts = ListFeed<Product>.Create(LoadProductsAsync);
string searchTerm = "";

var searchResults = allProducts
    .Where(p => string.IsNullOrEmpty(searchTerm) || 
                p.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
    .OrderBy(p => p.Name);

Summary Statistics

var orders = ListFeed<Order>.Create(LoadOrdersAsync);

// Multiple aggregates
var orderCount = orders.Aggregate(list => list.Count);
var totalValue = orders.Aggregate(list => list.Sum(o => o.Total));
var avgValue = orders.Aggregate(list => 
    list.Count > 0 ? list.Average(o => o.Total) : 0m);

// Combine into summary
var summaryFeed = orderCount.CombineWith(
    totalValue,
    avgValue,
    (count, total, avg) => new OrderSummary {
        OrderCount = count,
        TotalValue = total,
        AverageValue = avg
    });

Real-time Dashboard

var transactions = ListFeed<Transaction>.Create(LoadTransactionsAsync);

// Live updates on every transaction
var liveTotal = transactions.AggregateWithItemChanges(t => 
    t.Sum(x => x.Amount));

var liveCount = transactions.AggregateWithItemChanges(t => t.Count);

var recentTransactions = transactions
    .OrderByDescending(t => t.Timestamp)
    .Take(10);

Performance Notes

  • Select: O(1) per change (except indexed version)
  • Where: O(log n) per change using optimized index mapping
  • OrderBy: O(log n) insert using binary search, cached keys
  • Take/Skip: Rebuilds on any change (O(n))
  • Distinct: Rebuilds on any change (O(n))
  • Aggregate: O(n) per update
  • AsKeyed: O(1) key lookup, O(1) update by key
  • SelectFrom: O(1) per key/item change

AsKeyed

Create a keyed view over a list feed for O(1) key-based access.

public static KeyedListFeedView<TKey, T> AsKeyed<TKey, T>(
    this IListFeed<T> source,
    Func<T, TKey> keySelector)
    where TKey : notnull

Returns: KeyedListFeedView<TKey, T> implementing IKeyedListFeed<TKey, T>

Example:

var products = ListFeed<Product>.Create(LoadProductsAsync);

// Create keyed view
using var productsById = products.AsKeyed(p => p.Id);

// O(1) lookup by key
var product = productsById["SKU-12345"];

// O(1) update by key - no keySelector needed!
productsById.Update("SKU-12345", p => p with { Price = p.Price * 0.9m });

// O(1) check if exists
if (productsById.ContainsKey("SKU-99999"))
{
    Console.WriteLine("Found!");
}

// Safe lookup
if (productsById.TryGetValue("SKU-789", out var p))
{
    Console.WriteLine(p.Name);
}

Performance:

Operation Complexity
this[key] O(1)
ContainsKey O(1)
TryGetValue O(1)
Update(key, ...) O(1)
Remove(key) O(1)
IndexOfKey O(1)

Multiple keys on same source:

using var byId = products.AsKeyed(p => p.Id);
using var bySku = products.AsKeyed(p => p.Sku);
using var byBarcode = products.AsKeyed(p => p.Barcode);

// All views stay in sync with the source

With filtering:

// Only active products are indexed
var activeProductsById = products
    .Where(p => p.IsActive)
    .AsKeyed(p => p.Id);

Note: Remember to Dispose() keyed views when no longer needed.


SelectFrom

Create a feed that selects an item from a keyed list, automatically updating when either the key changes or the item at that key changes.

// For non-nullable key:
public static Feed<T?> SelectFrom<TKey, T>(
    this IFeed<TKey> keyFeed,
    IKeyedListFeed<TKey, T> keyedList)
    where TKey : notnull

// For nullable key (Nullable<T>):
public static Feed<T?> SelectFrom<TKey, T>(
    this IFeed<TKey?> keyFeed,
    IKeyedListFeed<TKey, T> keyedList)
    where TKey : struct

The Problem

When using immutable records and with expressions, derived feeds don't update when an item is replaced:

// This doesn't update when the item at the key changes!
SelectedAsset = SelectedAssetId.Select(id => id.HasValue ? Assets[id.Value] : null);

// Update the item:
Assets.Update(selectedId, asset => asset with { Name = "Updated" });
// SelectedAsset still references the OLD asset instance!

The Solution

// SelectFrom updates when:
// 1. SelectedAssetId changes (different item selected)
// 2. The item at SelectedAssetId is updated/replaced in Assets
SelectedAsset = SelectedAssetId.SelectFrom(Assets);

// Now when you update:
Assets.Update(selectedId, asset => asset with { Name = "Updated" });
// SelectedAsset automatically gets the new instance!

Complete Example

public partial class MainViewModel : ObservableObject
{
    [BindableFeed]
    public partial IncrementalListFeed<Guid, Asset> Assets { get; init; }

    [BindableFeed]
    public partial State<Guid?> SelectedAssetId { get; init; }

    [BindableFeed]
    public partial Feed<Asset?> SelectedAsset { get; init; }

    public MainViewModel()
    {
        Assets = IncrementalListFeed<Guid, Asset>
            .Create(LoadAssetsAsync, asset => asset.Id);

        SelectedAssetId = State<Guid?>.FromValue(null);
        
        // Automatic updates on key change OR item change
        SelectedAsset = SelectedAssetId.SelectFrom(Assets);
    }

    [RelayCommand]
    public void UpdateAsset()
    {
        if (SelectedAssetId is { HasValue: true, Value: { } selectedId })
        {
            // O(1) update - SelectedAsset automatically gets the new instance
            Assets.Update(selectedId, asset => asset with { Name = "Updated" });
            // No need to manually re-trigger SelectedAsset!
        }
    }
}

How It Works

SelectFrom subscribes to both:

  1. Key feed state changes — triggers re-evaluation when the key changes
  2. List feed item changes — triggers re-evaluation when the item at the current key is added, updated, or removed

Incremental behavior:

Change SelectFrom Behavior
Key changes Re-evaluates, returns new item
Item at key updated Re-evaluates, returns new item
Item at key removed Returns default
Item at key added Re-evaluates, returns item
Unrelated item changes No re-evaluation
List reset Re-evaluates

Comparison with Select

Approach Key Changes Item Changes
.Select(id => list[id]) ✓ Updates ✗ No update
.SelectFrom(list) ✓ Updates ✓ Updates

Performance

  • Creation: O(1)
  • Key change: O(1) dictionary lookup
  • Item change detection: O(1) key comparison
  • Memory: ~40 bytes overhead per SelectFrom instance

See Also