Skip to content

Storing POCOs

Tom Laird-McConnell edited this page May 24, 2026 · 1 revision

Storing POCOs

LottaDB can store any C# class (POCO)

By default, LottaDB automatically promotes all simple-type top-level properties (string, int, long, double, bool, DateTime, Guid) to queryable columns indexed in Lucene. If no [Key] property is defined, a ULID is auto-generated as the key.

Zero-attribute POCO storage

The simplest way to get started -- just define a plain class and store it:

public class Actor
{
    public string Username { get; set; } = "";
    public string DisplayName { get; set; } = "";
    public int FollowerCount { get; set; }
}

var db = await catalog.GetDatabaseAsync("mydb");

// A ULID key is auto-generated. All properties are queryable automatically.
await db.SaveAsync(new Actor { Username = "alice", DisplayName = "Alice", FollowerCount = 42 });
var results = db.Search<Actor>().Where(a => a.Username == "alice").ToList();

No [Key], no [Queryable] -- AutoQueryable handles it. Attributes are available for fine-tuning when you need more control.

Registering types

Tell LottaDB about your types when using attributes to fine tune behavior creating a database:

var db = await catalog.GetDatabaseAsync("mydb", config =>
{
    config.Store<Actor>();
    config.Store<Note>();
});

Attribute-based modeling

Attributes give you fine control over storage and indexing behavior:

  • [Key] marks the unique identity property. Supports manual values or auto-generated ULIDs.
  • [Queryable] makes a property queryable via LINQ, indexed in Lucene, and promoted to a Table Storage column. (With AutoQueryable on, simple-type properties are already queryable -- use [Queryable] to set specific modes like NotAnalyzed or Vector.)
  • [NotQueryable] excludes a property from AutoQueryable promotion. Use on large string properties that would exceed Azure Table Storage's 64KB column limit.
  • [DefaultSearch] (class-level) sets which property is the default target for free-text queries.
Attribute Target Purpose
[Key] Property Designates the key property. Without it, a ULID is auto-generated.
[Queryable] Property Marks a property as queryable with optional mode (NotAnalyzed, Vector).
[NotQueryable] Property Excludes a property from AutoQueryable promotion.
[DefaultSearch] Class Sets the default free-text search property.
public class Note
{
    [Key]
    public string NoteId { get; set; } = "";

    [Queryable(QueryableMode.NotAnalyzed)]  // exact match
    public string AuthorId { get; set; } = "";

    [Queryable]                              // full-text search (string default)
    public string Content { get; set; } = "";

    [Queryable(Vector = true)]               // full-text + vector similarity search
    public string Summary { get; set; } = "";

    [NotQueryable]                           // too large for a Table Storage column
    public string RawHtml { get; set; } = "";

    public DateTimeOffset Published { get; set; }  // auto-indexed by AutoQueryable
    public List<string> Tags { get; set; } = new(); // complex types stored in JSON, not indexed
}

Fluent modeling

For POCO classes you don't own, use fluent configuration:

config.Store<BareNote>(s =>
{
    s.SetKey(n => n.NoteId);
    s.AddQueryable(n => n.AuthorId).NotAnalyzed();
    s.AddQueryable(n => n.Content);
});

AutoQueryable

AutoQueryable is on by default for all registered types. It automatically promotes every public property of a simple type (string, int, long, double, bool, DateTime, Guid, and their nullable forms) to a queryable column in Table Storage and an indexed field in Lucene.

Properties that already have [Queryable] keep their explicit configuration. Properties marked [NotQueryable] are skipped. Complex types (lists, dictionaries, nested objects) are stored in JSON but not indexed.

To disable AutoQueryable for a type, use the fluent API:

config.Store<Note>(s =>
{
    s.AutoQueryable(false);  // only index explicitly configured properties
    s.AddQueryable(n => n.AuthorId).NotAnalyzed();
    s.AddQueryable(n => n.Content);
});

CRUD Operations

// Save
await db.SaveAsync(new Actor { Username = "alice", DisplayName = "Alice" });

// Get
var actor = await db.GetAsync<Actor>("alice");

// GetMany with server-side filter
await foreach (var note in db.GetManyAsync<Note>(n => n.AuthorId == "alice")) { ... }

// Search (Lucene full-text)
var results = db.Search<Note>("lucene").ToList();
var results = db.Search<Note>(n => n.Content.Contains("lucene")).ToList();

// Delete
await db.DeleteAsync<Note>("note-123");

// Bulk save
await db.SaveManyAsync([actor1, actor2, note1]);

// Optimistic concurrency (read-modify-write)
await db.ChangeAsync<Actor>("alice", a => a.Counter++);

All returned objects are annotated with key, ETag, and cached JSON via Object Metadata.

Polymorphism

Register a base type and its derived types. Queries on the base type return all subtypes:

config.Store<Person>();
config.Store<Employee>();  // extends Person

// Returns both Person and Employee instances
var people = db.GetManyAsync<Person>().ToList();

// Search also returns subtypes
var results = db.Search<Person>("department:engineering").ToList();

Default Search Property

By default, LottaDB creates a synthetic _content_ field that concatenates all analyzed string properties for free-text search. You can override this with [DefaultSearch]:

Attribute-based:

[DefaultSearch(nameof(Content))]
public class Article
{
    [Key]
    public string Id { get; set; } = "";

    [Queryable]
    public string Title { get; set; } = "";

    [Queryable]
    public string Body { get; set; } = "";

    [Queryable(Vector = true)]
    public string Content { get => $"{Title} {Body}"; }  // composed search field
}

Fluent:

config.Store<Article>(s =>
{
    s.SetKey(a => a.Id);
    s.AddQueryable(a => a.Title);
    s.AddQueryable(a => a.Body);
    s.AddQueryable(a => a.Content).Vector();
    s.DefaultSearch(a => a.Content);
});

Now Search<Article>("lucene") targets Content, while a.Title.Query("lucene") still targets Title directly.

Triggers

Register handlers that run after save or delete:

config.On<Note>(async (note, kind, db) =>
{
    if (kind == TriggerKind.Saved)
        Console.WriteLine($"Note saved: {note.NoteId}");
});

See Triggers and Views for materialized views and cascading handlers.

Clone this wiki locally