Not every application needs SQL. Sometimes you just want to store and retrieve C# objects by key, with optional indexing for fast lookups. That's what the CSharpDB Collection API is built for.
Collections give you a document-oriented interface on top of the same B+tree storage engine that powers CSharpDB's SQL tables — so you get crash recovery, MVCC isolation, and WAL durability without writing a single SQL statement.
Defining Your Model
Collections work with any serializable C# type. Records work particularly well:
public record Customer(
string Name,
string Email,
Address Address,
string[] Tags
);
public record Address(
string Street,
string City,
string Country
);
CRUD Operations
Every collection is a key-value store where keys are strings and values are your typed objects:
using CSharpDB.Engine;
await using var db = await Database.OpenAsync("shop.db");
var customers = await db.GetCollectionAsync<Customer>("customers");
// Create
var alice = new Customer(
"Alice", "alice@example.com",
new Address("123 Main St", "Portland", "US"),
["premium", "early-adopter"]
);
await customers.PutAsync("alice", alice);
// Read
var customer = await customers.GetAsync("alice");
Console.WriteLine(customer?.Name); // "Alice"
// Update — just put again with the same key
var updated = alice with { Email = "alice@newdomain.com" };
await customers.PutAsync("alice", updated);
// Delete
await customers.DeleteAsync("alice");
Indexing Properties
Without indexes, the only way to find documents is by key or by scanning. Indexes let you query by any property:
// Index a top-level property
await customers.EnsureIndexAsync(c => c.Email);
// Index a nested property
await customers.EnsureIndexAsync(c => c.Address.City);
// Query by indexed property
await foreach (var match in customers.FindByIndexAsync(
c => c.Address.City, "Portland"))
{
Console.WriteLine($"{match.Key}: {match.Value.Name}");
}
Indexes are persisted in the database file. EnsureIndexAsync is idempotent — calling it multiple times has no effect if the index already exists.
Scanning and Filtering
For ad-hoc queries or when you don't have an index, use ScanAsync with LINQ:
// Scan all entries
await foreach (var entry in customers.ScanAsync())
Console.WriteLine($"{entry.Key}: {entry.Value.Name}");
// Count entries
var count = await customers.CountAsync();
Console.WriteLine($"Total customers: {count}");
Mixing SQL and Collections
Collections and SQL tables coexist in the same database file. You can use both APIs in the same application — SQL for relational data and collections for document-oriented data:
await using var db = await Database.OpenAsync("myapp.db");
// SQL for relational data
await db.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS Orders (
Id INTEGER PRIMARY KEY,
CustomerId TEXT NOT NULL,
Total REAL
)");
// Collections for document data
var profiles = await db.GetCollectionAsync<Customer>("profiles");
await profiles.PutAsync("alice", alice);
Best Practices
- Use meaningful keys. Keys are your primary access path. Natural keys (email, slug, SKU) are often better than GUIDs.
- Index properties you query by. Without an index, lookups require a full scan.
- Keep documents reasonably sized. Collections are backed by B+tree pages. Very large documents may span multiple overflow pages.
- Use records for immutability. The
withexpression pattern makes updates clean and predictable.