Scan and query
Zaris lets you work with whole datasets, not just individual keys. You have two tools: scan iterates over keys by prefix, and search filters indexed data. Search is not automatic — you enable it by attaching labels when you write.
Why this matters
Basic operations require an exact key.
await client.GetAsync("user:1");
Real applications need to ask questions across many records: find all customers in London, get users with an age between 28 and 32, or return the top five records by age. Zaris answers these with two mechanisms.
- Scan iterates over data by key prefix. No index is needed.
- Search queries indexed metadata. It needs labels.
The scan and search surface lives on IZaris, which extends IZarisClient with the Scan accessor. IZarisClientProvider.GetAsync is typed to return IZarisClient, so obtain the client as IZaris before calling client.Scan:
var client = (IZaris)await provider.GetAsync("default");
Entities and labels: the foundation of search
Search runs against indexed metadata, not raw stored values. That metadata comes from entities and labels, so you must supply them at write time.
An entity names a logical type of data, such as customer, order, or session. It groups related records and defines what is indexed.
A label is an indexed field attached to a key, such as city or age. Labels are what make filtering, sorting, and aggregation possible.
The mental model is a pipeline:
Entity + labels → index → search → fast queries
Your search is only as capable as the labels you attach. A field with no label cannot be filtered or sorted.
Store data with labels
Attach an entity and labels through Put options when you write. Only labeled fields become searchable.
var putOpts = Put.WithEntity("customer")
.WithLabel("city", "London")
.WithLabel("age", "30")
.WithLabel("email", "user1@example.com");
await client.PutAsync("cust:1", customer, putOpts);
Labels are captured at write time. A field you did not label when you stored the record is invisible to search — you cannot query it retroactively. To make an existing field searchable, rewrite the record with the new label. Decide your searchable fields before you write.
Scan: iterate without an index
ScanAsync streams keys under a prefix. It needs no labels, which makes it the right tool for simple iteration.
await foreach (var item in client.Scan.ScanAsync(new KeyRange { Prefix = "cust:" }))
{
Console.WriteLine(item.Value);
}
ScanAsync takes a KeyRange (set its Prefix to scan by prefix) and yields a KvResult<string> per key, where item.Value is the key.
Scan streams results sequentially and does not filter beyond the prefix. It reads through the matching keys in order, so it is well suited to walking a dataset but not to targeted lookups.
Search: query by label
Search uses the label index to filter efficiently. You build a query with SearchQuery and run it through client.Scan.SearchAsync. The following sections show the clause types you can compose.
Equality
Match records where a label equals a value.
var query = SearchQuery
.For("customer")
.Eq("city", "London");
Range
Match records where a numeric label falls within a range.
var query = SearchQuery
.For("customer")
.Range("age", 28, 32);
Prefix
Match records where a label starts with a given string.
var query = SearchQuery
.For("customer")
.LikePrefix("email", "user1");
Logical combinations
Combine clauses with And, Or, and Not to express precise conditions.
var query = SearchQuery
.For("customer")
.And(
new EqClause("city", "Berlin"),
new EqClause("age", "32"));
var query = SearchQuery
.For("customer")
.Or(
new EqClause("city", "Lahore"),
new EqClause("age", "25"));
var query = SearchQuery
.For("customer")
.Not(new EqClause("city", "Lahore"));
You can also nest clauses directly for more complex logic. This query matches ages 20 through 65 that are not in Lahore.
var query = new SearchQuery("customer", new IClause[]
{
new AndClause(new List<IClause>
{
new RangeClause("age", 20, 65),
new NotClause(new EqClause("city", "Lahore"))
})
});
Sort and limit
Add OrderBy and Limit to control ordering and result size. This returns the five oldest customers.
var query = SearchQuery
.For("customer")
.OrderBy("age", ascending: false)
.Limit(5);
Chain OrderBy for multi-field sorting. Earlier calls take precedence; later ones break ties.
var query = SearchQuery
.For("customer")
.OrderBy("city", ascending: true)
.OrderBy("age", ascending: true)
.Limit(10);
Result shapes
SearchAsync returns a streamed result you can read in different shapes depending on how much of each record you need. Each reader is disposable, so bind it with await using.
Read keys only when you just need identifiers.
await using var reader =
await (await client.Scan.SearchAsync(query)).AsKeys();
Read full entries when you need the metadata. Each entry exposes its labels through Current.Metadata.Labels.
await using var reader =
await (await client.Scan.SearchAsync(query)).AsEntries();
while (await reader.ReadAsync())
{
var labels = reader.Current.Metadata.Labels;
Console.WriteLine(labels["city"].Value);
}
Read strongly typed entities when you want deserialized objects.
await using var reader =
await (await client.Scan.SearchAsync(query))
.AsEntities<Customer>();
Project selected fields to reduce the payload. Declare the fields on the query with Select, and select them again on the reader.
var query = SearchQuery
.For("customer")
.Eq("city", "New York")
.Select("email");
await using var reader =
await (await client.Scan.SearchAsync(query))
.Select(new[] { "email" });
Search results stream, and every reader is IAsyncDisposable. Always bind it with await using (as shown above) so the reader and its server-side resources are released even if you stop reading early or an exception is thrown. A reader you forget to dispose leaks resources for the life of the client.
Paging
Search results are paged internally. Set PageSize through SearchOptions to control how many records are fetched per page.
var opts = new SearchOptions
{
PageSize = 100
};
await client.Scan.SearchAsync(query, opts);
Paging keeps memory bounded when you process large datasets, because you never hold the whole result set in memory at once. All results stream, so you process records incrementally.
Scan compared with search
Choose based on whether you have labels and what you are trying to do.
| Feature | Scan | Search |
|---|---|---|
| Requires labels | No | Yes |
| Access pattern | Sequential | Indexed |
| Use case | Iteration | Filtering and querying |
Use scan when you have no labels and just need to walk a set of keys. Use search when you need filtering, want indexed performance, or already store labeled data. For production query paths, prefer search.
Best practices
Follow these practices to get correct, efficient queries.
- Define an entity for any structured data you plan to query.
- Add a label for every field you will filter or sort on, and add it at write time.
- Prefer search over scan for query paths in production.
- Set
PageSizeonSearchOptionsfor large datasets. - Pick the narrowest result shape you need —
AsKeys,AsEntries, orAsEntities<T>— and use projection to trim the payload. - Combine clauses to filter precisely rather than filtering in your own code after a broad scan.
Next steps
- Transactions to group operations into a consistent unit.
- Serialization to control how entity objects are stored.