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 .

A focused product lookup within an indexed catalogue.
A focused product lookup within an indexed catalogue.
💡 In short. Look up stored objects through ObjectSpace member indexes, covering the identifier, unique and nonunique index kinds, group lookups that keep tracked identity, and how saves and deletes maintain the indexes.

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.

C#
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.

  • WithIdentifier and WithUniqueIndexOn each create an IUniqueProjectionIndex<TKey>. Its Values member is an IReadOnlyDictionary<TKey, long>, so a key returns one slot.
  • WithIndexOn creates an IProjectionIndex<TKey>. Its Values member is an ILookup<TKey, long>, so a key returns a group of slots.

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.

C#
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.

C#
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.

  1. EnterAccessScope() holds the space's lock while the index is read and the objects are loaded.
  2. GetIndexFor(member) finds the index named after the member, and Values[key] returns the slots stored under that key. Because Values is an ILookup, an absent key gives an empty group rather than an exception.
  3. 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.

C#
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.

C#
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.

C#
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

⚠️ Keep these limits in mind.
  • 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) or TryGetUniqueIndex<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.

Bash
dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework

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.

Bash
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.

Plain text
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.


sample.zip
Download the complete worked example and build instructions (ZIP).

The Sphere10 Framework overview describes the rest of the toolkit.

Author: Herman Schoenfeld