How to Query Objects Without Scanning Every Record
Finding a stored object by one of its members should not require loading and testing every record. An ObjectSpace in Sphere10 Framework keeps a persistent index for each member you declare, and each index maps a key directly to the storage slots that hold matching objects.
This article explains the three index kinds, how to look up one object or a group of objects through them, and how the indexes stay correct as objects are saved, changed and deleted. It builds on the store created in the embedded object database article .
1. Declare the indexes
Indexes are declared on the dimension when the store is built. The sample stores two plain classes, Customer and Purchase, and the excerpts below use Purchase.
builder.AddDimension<Purchase>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithUniqueIndexOn(item => item.Reference)
.WithIndexOn(item => item.CustomerId)
.OptimizeAssumingAverageItemSize(256)
.Done();
WithIdentifier makes Id the primary key. WithUniqueIndexOn adds a second key that must be unique. WithIndexOn adds a nonunique index, so many records can share one CustomerId. The same three declarations are available as the [Identity], [UniqueIndex] and [Index] attributes.
2. How member indexes work
Every dimension of an ObjectSpace stores its objects in numbered slots. A member index is a B-tree, stored in the same file, whose keys are the values of one member and whose values are slot numbers. A lookup reads the index, obtains the slots and loads only those objects.
The kind of index decides what a key can return.
-
WithIdentifierandWithUniqueIndexOneach create anIUniqueProjectionIndex<TKey>. ItsValuesmember is anIReadOnlyDictionary<TKey, long>, so a key returns one slot. -
WithIndexOncreates anIProjectionIndex<TKey>. ItsValuesmember is anILookup<TKey, long>, so a key returns a group of slots.
Diagram source
---
config:
sequence:
actorMargin: 8
width: 96
mirrorActors: false
messageMargin: 28
noteMargin: 8
---
sequenceDiagram
participant C as Caller
participant I as Member index
participant S as ObjectSpace
C->>I: Values[key]
I-->>C: slot numbers
loop each slot
C->>S: Get(slot)
S-->>C: tracked object
end
3. Look up one object by a unique key
Get and TryGet accept a member expression and a key. They resolve the identifier or unique index that was declared on that member and return the single matching object.
var customer = reopened.Get((Customer item) => item.Email, "[email protected]");
var moved = space.Get((Purchase item) => item.Id, 101L);
Get throws when the key is absent, and TryGet returns false instead. Both go through the space's identity tracker, so asking for the same key twice returns the same instance.
4. Resolve a group and retain tracked identity
A nonunique index is reached through the dimension's object stream. The sample wraps the lookup in one generic helper.
public static T[] By<T, TKey>(ObjectSpace space, Expression<Func<T, TKey>> member, TKey key) {
using (space.EnterAccessScope()) {
var dimension = (ObjectSpace.Dimension<T>)space.Dimensions.Single(item => item.Definition.ObjectType == typeof(T));
var positions = dimension.Container.ObjectStream.GetIndexFor(member).Values[key].ToArray();
return positions.Select(space.Get<T>).ToArray();
}
}
The helper does three things.
-
EnterAccessScope()holds the space's lock while the index is read and the objects are loaded. -
GetIndexFor(member)finds the index named after the member, andValues[key]returns the slots stored under that key. BecauseValuesis anILookup, an absent key gives an empty group rather than an exception. -
space.Get<T>(slot)loads each object through the identity tracker.
The last step matters. The container can also deserialize raw objects, but those copies are not tracked, and saving one would not update the record it was read from. Loading through the space keeps one instance for each slot.
Calling the helper is a single line for each lookup.
var first = IndexQueries.By(reopened, (Purchase item) => item.CustomerId, customer.Id);
var second = IndexQueries.By(reopened, (Purchase item) => item.CustomerId, 2);
A condition on two members starts from the more selective index and filters the returned objects in memory. The application chooses that plan, because the store does not translate predicates into index lookups.
5. Indexes follow every save and delete
Save and Delete maintain every index of the dimension, so there is no separate reindex step. Changing an indexed member and saving the object moves it from one key's group to another.
var moved = space.Get((Purchase item) => item.Id, 101L);
moved.CustomerId = 2;
space.Save(moved);
After this save, the group for key 1 no longer contains the object and the group for key 2 does. Deleting an object removes its entries and recycles its slot, and a later insert may reuse that slot.
space.Delete(space.Get((Purchase item) => item.Id, 102L));
space.Save(new Purchase { Id = 103, Reference = "PO-103", CustomerId = 1, Total = 60m });
Slot numbers are therefore storage positions and not stable identifiers. Hold on to keys, and resolve slots through an index each time. The indexes are part of the file, so the same lookups work after Commit() and a reopen without being rebuilt.
6. Limits to plan for
-
No query translation. Lookups use a declared index or
GetAll<T>(). LINQ predicates are not turned into index plans. -
Indexes are part of the stored layout. Adding or removing an index changes the file format, and an existing file is not upgraded automatically. Reopen a file with the same index declarations and B-tree order it was created with.
-
Named indexes. The member-expression methods find an index by member name. An index given an explicit name needs
TryGetIndex<TMember>(name, out index)orTryGetUniqueIndex<TMember>(name, out index). -
Write cost. Each index adds storage and work to every save, so index the members you actually look up.
7. Get the package and run the sample
Use .NET 10 and the Sphere10.Framework NuGet package.
dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework --version 3.1.3
The complete MIT-licensed sample contains the project and every file shown here. Extract the download over the generated project and run it against a new directory.
dotnet run -c Release -- ./fresh-index-demo
The sample runs each lookup above, compares the indexed results with a GetAll<T>() scan, then repeats the lookups after a commit and reopen.
Purchases for [email protected] by index: 2
Indexed IDs match saved-record scan: passed
Move, delete, insert and reopen indexes: passed
Missing key group: empty
The next article covers
commit and rollback
. The
ObjectSpace query contract
documents Get, TryGet, GetAll and the index methods in full.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld