Skip to main content

Index and search

Every CatDb table is ordered by its primary key — the key is the index. The fluent query builder gives you a consistent API for both primary-key range scans and secondary index lookups.

All queries are lazy: no work happens until you enumerate the result (foreach, .ToList(), .FirstOrDefault()) or call a terminal method (.Count(), .Exists()).

using CatDb.Extensions;

Primary key queries

Call table.Query() with no arguments to start a primary-key builder, then chain bound methods and enumerate.

// All records with key >= 100
var rows = table.Query().AtLeast(100).ToList();

// Keys in the range [100, 200]
var range = table.Query().AtLeast(100).AtMost(200).ToList();

// Keys > 500 (exclusive lower bound)
var newer = table.Query().GreaterThan(500).ToList();

// Descending — last 5 records
var last5 = table.Query().Backward().Take(5).ToList();

// String prefix
var byPrefix = table.Query().StartsWith("ada").ToList();

// Count without materializing records
long n = table.Query().AtLeast(100).AtMost(200).Count();

Builder methods

MethodMeaning
.AtLeast(from)Keys >= from
.GreaterThan(from)Keys > from (exclusive)
.AtMost(to)Keys <= to
.LessThan(to)Keys < to (exclusive)
.Between(from, to)Both bounds inclusive by default
.Between(from, to, fromInclusive, toInclusive)Fine-grained inclusivity
.Backward()Scan in descending key order
.Take(n)Stop after n records
.Skip(n)Skip first n records (prefer cursor paging for deep pages)
.Where(predicate)Post-scan key filter
.Count()Count matching records
.StartsWith(prefix)String key prefix scan (extension — see below)

Key types

Keys are not limited to primitives. Any type with a registered comparer works — long, string, Guid, composite Slots<>, or your own class.

// Composite key example
var table = engine.OpenXTable<Slots<string, int>, Order>("orders");
var results = table.Query().AtLeast(new Slots<string, int>("acme", 0))
.AtMost(new Slots<string, int>("acme", int.MaxValue))
.ToList();

Secondary index queries

Create a secondary index on any field, then query it with the same fluent style — no manual generic type arguments required.

Creating indexes

var table = engine.OpenXTable<long, Customer>("customers");

// Unique index via lambda
table.CreateIndex("Email", c => c.Email, IndexType.Unique);

// Non-unique index
table.CreateIndex("City", c => c.City, IndexType.NonUnique);

// Composite index (field names)
table.CreateIndex("CityAge", new[] { "City", "Age" }, IndexType.NonUnique);

Lookup by equality

// Exact match on a unique index
var customer = table.Query(c => c.Email).Equals("ada@example.com").FirstOrDefault();

// All customers in a city
var inLondon = table.Query(c => c.City).Equals("London").ToList();

// Count without fetching records
long count = table.Query(c => c.City).Equals("London").Count();

// Check existence
bool exists = table.Query(c => c.Email).Equals("old@example.com").Exists();

Range scan on an index

// All emails starting with "ada"
var results = table.Query(c => c.Email).StartsWith("ada").ToList();

// Emails in a range
var emails = table.Query(c => c.Email)
.AtLeast("a@example.com")
.AtMost("b@example.com")
.ToList();

// Numeric range on a field
var young = table.Query(c => c.Age).Between(18, 25).ToList();

// Limit results
var top10 = table.Query(c => c.City).Equals("NYC").Take(10).ToList();

Index builder methods

MethodMeaning
.Equals(value)Exact field match
.AtLeast(from)Field values >= from
.GreaterThan(from)Field values > from
.AtMost(to)Field values <= to
.LessThan(to)Field values < to
.Between(from, to)Inclusive range
.Take(n)Limit results
.Count()Count matches
.Exists()Returns true if at least one match exists
.StartsWith(prefix)String field prefix (extension)

Unique constraint

Writing a record that would duplicate a unique index value throws UniqueIndexViolationException.

try
{
table.Replace(99, new Customer { Email = "ada@example.com" });
}
catch (UniqueIndexViolationException ex)
{
Console.WriteLine($"Duplicate: index '{ex.IndexName}'");
}

Index maintenance

Indexes are updated automatically on every Replace and Delete call. Always Commit() in the same unit of work as your writes.

table.Replace(42, updatedCustomer); // index updated here
engine.Commit(); // flushed to disk

StartsWith works for both primary-key and index queries when the field type is string. It computes the exclusive upper bound from the prefix so only the matching leaf nodes are loaded.

// Primary key prefix
var names = engine.OpenXTable<string, long>("name-to-id");
foreach (var row in names.Query().StartsWith("ada"))
Console.WriteLine($"{row.Key} -> {row.Value}");

// Index field prefix
foreach (var row in table.Query(c => c.Email).StartsWith("ada"))
Console.WriteLine(row.Value.Name);

Count

// Primary key range count
long n = table.Query().Between(100, 200).Count();

// Full table count
long total = table.Query().Count();

// Index count
long londonCount = table.Query(c => c.City).Equals("London").Count();

For local XTable / XTablePortable tables without a .Where() filter, CatDb counts bounded ranges using leaf-level index arithmetic instead of materializing every record.


Paging

Prefer keyset paging for deep pages — it is O(log n) per page:

var query = table.Query().AtLeast(0L);

var page1 = table.PageAfter(query.BuildKeyQuery(), take: 100).ToList();
var page2 = table.PageAfter(query.BuildKeyQuery(),
afterKey: page1.Last().Key, take: 100).ToList();

Or use the builder's .After() shorthand:

var page2 = table.Query().AtLeast(0L).After(page1.Last().Key).Take(100).ToList();

Offset paging is available but scans every skipped row:

var page = table.Query().AtLeast(0L).Skip(10_000).Take(100).ToList();