Indexes

MemDb supports two types of indexes, both configured on the builder before calling Register. All index data is held in RAM only — index configuration must be present on every ConfigureFor call and is rebuilt each time the database is opened.

Identity Index

The identity index is the equivalent of a primary key index, built on the unique long id auto-assigned by MemDb on every insert. This index is disabled by default. Enable it during configuration:

MemDb.ConfigureFor<Person>("people", _dbDirectory)
    .IndexOnIdentity(shouldIndex: true)
    .Register();

When enabled, the identity index is used automatically by every by-id operation: Exists(long id), Find(long id), FindAll(params long[] ids), Update(Action<T> apply, long id), and Delete(long id). When disabled, these operations perform a full scan.

Indexes are never used when filtering by predicate (Func<T, bool>). Any predicate-based method — Find(where), FindAll(where), Query().Where(...) — always performs a full scan regardless of what indexes are configured.

Applied Indexes

An applied index can be placed on any property whose type implements IComparable. Unlike the identity index, applied index values are not required to be unique — multiple records can share the same value (e.g., several people with the same last name).

Indexed properties cannot hold a null value. If an indexed property can be null in your domain, use a custom IMemDbCloner<T> to coalesce null to a safe sentinel value at the cache boundary:

// null coalesced to empty — safe for indexing
record.Ssn = value.Ssn ?? string.Empty;

Naming an applied index

Each applied index has a string name that the query API references when calling QueryViaIndex. Prefer nameof over a string literal.

MemDb.ConfigureFor<Person>("people", _dbDirectory)
    .ApplyIndex<string>(nameof(Person.LastName), p => p.LastName)
    .ApplyIndex<int>(nameof(Person.CreditScore), p => p.CreditScore)
    .Register();
Index data is RAM-only (unrelated to the .map file) and rebuilt from scratch on each open — index configuration must be present on every ConfigureFor call.

Applied indexes are maintained automatically on every insert, update, and delete.

Custom Comparers

Use a custom comparer when you need control over how index values are compared and sorted — for example, case-insensitive string equality and ordering, culture-aware sorts, or custom equality on a domain type.

IMemDbComparer<YIndex> combines IComparer<YIndex> and IEqualityComparer<YIndex> into a single interface. The built-in MemDbComparer<YIndex> class implements it by wrapping separate equality and relational comparer instances — many BCL comparers (such as StringComparer) implement both, so a single instance can serve both arguments:

var caseInsensitive = new MemDbComparer<string>(
    equality: StringComparer.OrdinalIgnoreCase, 
    relational: StringComparer.OrdinalIgnoreCase
);

MemDb.ConfigureFor<Person>("people", _dbDirectory)
    .ApplyIndex<string>(nameof(Person.LastName), p => p.LastName, caseInsensitive)
    .Register();

For fully custom comparison logic, implement IMemDbComparer<YIndex> directly.

Querying via an Applied Index

Call db.QueryViaIndex<YIndex>(indexName) and chain one of the index operators to define the filter. The same builder and terminal methods available on db.Query() follow.

// Full flow: QueryViaIndex → operator → builders → terminal
Person[] marshFamily = db.QueryViaIndex<string>(nameof(Person.LastName))
    .IsEqualTo("Marsh")
    .OrderBy((p1, p2) => p1.FirstName.CompareTo(p2.FirstName))
    .ToArray();

A Where predicate can be chained after the index operator; it filters only the records the index returned, not the full dataset.

Person[] result = db.QueryViaIndex<string>(nameof(Person.LastName))
    .IsEqualTo("Marsh")
    .Where(p => p.IsAdult)
    .ToArray();

Index Operators

OperatorSignatureLookup mechanismComplexity
IsEqualToIsEqualTo(YIndex key)Hash lookup into pointer setO(1)
InIn(params YIndex[] keys)Hash lookup per key, results unionedO(k) — k = key count
IsNotEqualToIsNotEqualTo(YIndex key)Binary search to exclude matching setO(log n)
IsGreaterThanIsGreaterThan(YIndex key)Binary search for lower boundO(log n)
IsLessThanIsLessThan(YIndex key)Binary search for upper boundO(log n)
IsGreaterThanEqualToIsGreaterThanEqualTo(YIndex key)Binary search for lower bound (inclusive)O(log n)
IsLessThanEqualToIsLessThanEqualTo(YIndex key)Binary search for upper bound (inclusive)O(log n)
IsBetweenIsBetween(YIndex lower, YIndex upper)Binary search on both boundsO(log n)
IsNotBetweenIsNotBetween(YIndex lower, YIndex upper)Binary search to exclude rangeO(log n)
// IsEqualTo — all members of the Broflovski family
Person[] broflovskis = db.QueryViaIndex<string>(nameof(Person.LastName))
    .IsEqualTo("Broflovski")
    .ToArray();

// In — all members of two families at once
Person[] people = db.QueryViaIndex<string>(nameof(Person.LastName))
    .In("Broflovski", "Marsh")
    .ToArray();

// IsNotEqualTo — everyone without a perfect credit score
Person[] notPerfect = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsNotEqualTo(850)
    .ToArray();

// IsGreaterThan / IsLessThan
Person[] goodCredit = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsGreaterThan(720).ToArray();
Person[] badCredit  = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsLessThan(650)
    .ToArray();

// IsGreaterThanEqualTo / IsLessThanEqualTo
Person[] goodCreditInclusive = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsGreaterThanEqualTo(720)
    .ToArray();
Person[] badCreditInclusive  = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsLessThanEqualTo(650)
    .ToArray();

// IsBetween / IsNotBetween — any IComparable type works; here using DateTime
Person[] genX = db.QueryViaIndex<DateTime>(nameof(Person.BirthDate))
    .IsBetween(new DateTime(1965, 1, 1), new DateTime(1980, 12, 31))
    .ToArray();

Person[] notGenX = db.QueryViaIndex<DateTime>(nameof(Person.BirthDate))
    .IsNotBetween(new DateTime(1965, 1, 1), new DateTime(1980, 12, 31))
    .ToArray();

Available Methods After an Index Operator

The full set of builder and terminal methods from db.Query() are available after an index operator, with one addition: Count() is only available on index queries — use db.Count(predicate) for counting without an index.

CategoryMethods
BuildersWhere, OrderBy, Skip, Limit, GroupBy
Terminals — materializationToArray, Select, SelectDistinct
Terminals — aggregatesCount, Sum, Min, Max, Avg
Terminals — query-scoped writeUpdate, Delete

Where filters the records returned by the index lookup — not the full dataset.

// Count — how many Marsh family members that are adults
int marshCount = db.QueryViaIndex<string>(nameof(Person.LastName))
    .IsEqualTo("Marsh")
    .Where(p => p.IsAdult)
    .Count();

// OrderBy descending — note reversed comparison arguments for descending sort
Person[] bestCreditFirst = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsGreaterThan(720)
    .Where(p => p.IsAdult)
    .OrderBy((p1, p2) => p2.CreditScore.CompareTo(p1.CreditScore))
    .ToArray();

// Skip + Limit — paginate, youngest first
Person[] page = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsGreaterThan(600)
    .Where(p => p.IsAdult)
    .OrderBy((p1, p2) => p2.BirthDate.CompareTo(p1.BirthDate))
    .Skip(5)
    .Limit(5)
    .ToArray();

// Where secondary filter — sum net worth of adults with credit score above 650
decimal netWorth = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsGreaterThan(650)
    .Where(p => p.IsAdult)
    .Sum(p => p.NetWorth);

// Query-scoped update — boost credit score for high net worth adults
int updated = db.QueryViaIndex<decimal>(nameof(Person.NetWorth))
    .IsGreaterThanEqualTo(100_000)
    .Where(p => p.IsAdult)
    .Update(p => p.CreditScore = p.CreditScore + 10);

// Query-scoped delete — delete up to 2 adults with credit score under 600, lowest first
int deleted = db.QueryViaIndex<int>(nameof(Person.CreditScore))
    .IsLessThan(600)
    .Where(p => p.IsAdult)
    .OrderBy((p1, p2) => p1.CreditScore.CompareTo(p2.CreditScore))
    .Limit(2)
    .Delete();