Searching and exact values

The examples use the StructureRecord class and store from Declaring, storing, and fetching records.

Searching

store.searcher() opens a query through the backend-agnostic protocols in httk.store.query: bind classes to variables and add conditions. Freeze the query into the user-facing lazy result set with results(). Variables of the same class self-join; reference fields chain (v.reference.name), variable-length fields support the set operations (has_any, has_only), and ~ negates them as sets. String matching (contains, startswith, endswith) always takes literal text — % and _ match themselves:

search = store.searcher()
s = search.variable(StructureRecord)
search.add(s.spacegroup == 225)
search.add(s.reference.name == "Ada")  # auto-joins the author table
search.add(s.symbols.has_only("O", "Ca", "Ti"))  # for-all over the child rows
search.add(~s.symbols.has_any("Fe"))  # no child row is iron
results = search.results(structure=s, energy=s.energy)
for row in results:  # lazy ResultRow values
    print(row.structure.formula, row.energy)  # exact rational energy

ResultRow supports names, attributes, and positions. scalars() is the short form for a one-column result (or takes a column name), and first() and one() return one row; one() raises NoResultError or MultipleResultsError unless there is exactly one. Results are reusable: len(results), results[1:3], and re-iteration are all supported. A slice is a view over its own positions: iteration, len(), indexing, first(), one(), and column() are all scoped to that slice without re-querying.

Scalar columns stay exact by default. column() returns a ResultColumn with an explicit approximate view through .floats() and an exact rational tensor through .to_fracvector() for integer, fraction, and fracscalar columns:

energies = results.column("energy")
exact = list(energies)
approximate = list(energies.floats())
as_vector = energies.to_fracvector()

.to_fracvector() rejects floats, surds, strings, datetimes, and other non-rational projections. Variable-length CHILD-role projections are rejected when results() is declared; reference-path projections are supported.

Pandas-style slicer indexing

search.slicer(cls) wraps this same DSL in a [] indexing surface that reads like pandas. It is pure sugar — it compiles bracket indexing into the variable/add/output/results calls above and adds no query capability of its own:

note = store.searcher().slicer(StructureRecord)
list(note["formula"])                        # one field's decoded values
list(note[note["energy"] < 0])               # records a boolean mask selects
len(note[note["spacegroup"] == 225])         # count of a selection

A field-name string gives a column (iterate for values); comparisons and the helpers isin, isna, notna, between, and the literal .str.contains/startswith/endswith build a boolean mask; indexing with a mask gives a selection (iterate for reconstructed records, or take its len()). Masks combine with &, |, ^, ~, where both operands must be masks of the same slicer. Every operation runs against a fresh searcher, so a filtered selection never leaks its condition into the next operation, and iterating the slicer itself yields every record.

The slicer never sorts (some conforming stores reject ordering) and offers no .loc/.iloc, integer/slice indexing, or multi-column selection — use the plain searcher for those. The same slicer(cls) method is available on the Mongo and federated searchers.

Continuation pages

SqlResultSet.page() is an optional capability (described neutrally by httk.store.PageableResultSetLike), separate from the required ResultSetLike contract. It uses a stable keyset/seek order over named root scalar result projections and returns an immutable ResultPage:

from httk.store import PageOrder

page = results.page(
    size=100,
    order_by=(PageOrder("spacegroup"), PageOrder("energy", descending=True)),
)
for row in page.rows:
    print(row.structure.formula, row.energy)

if page.next is not None:
    later = results.page(
        size=100,
        order_by=(PageOrder("spacegroup"), PageOrder("energy", descending=True)),
        cursor=page.next,
    )

PageOrder.name refers to the output name ("energy" above), not a SQLAlchemy column. Ordering accepts root scalar and encoded-scalar projections; object outputs, child/reference-derived keys, duplicate names, an existing add_sort(), a nonzero query offset, and a query limit are rejected. The SQL implementation always appends the root sid as an internal ascending tie-breaker, so duplicate user-order values do not duplicate or skip rows. nulls="first"/"last" is explicit and portable across SQLite and DuckDB. An empty order tuple is valid when storage order by root sid is sufficient.

The opaque URL-safe ContinuationToken contains only a version, tagged anchor values, root sid, direction, and a digest binding the frozen query/output/order schema and dialect. It contains no SQL and decoded values are always bound parameters. It is deliberately not authenticated: a web boundary can wrap its string value in an HMAC or another authenticated envelope. Corrupt, oversized, non-canonical, or mismatched tokens raise PaginationCursorError; do not construct application tokens yourself.

Pages are live: every call uses a fresh read connection and does not keep a driver cursor or transaction open. On an unchanged store, following next and then previous recovers the original page. Concurrent direct database changes can move, insert, or remove matches between calls, so continuation paging does not promise snapshot consistency.

A page fetches at most size + 1 root match rows and uses a lexicographic seek predicate rather than a query offset; the library hard-caps size at 10,000. include_total=False (the default) does not count. include_total=True runs the normal exact SQL count separately. This bounds application memory and match row transfer, not database CPU for arbitrary filters or sorts; indexed root order fields benefit from the indexes declared in the schema.

cursor() bounds the number of hydrated record/proxy objects held by a row-by-row consumer, but not the raw values pinned by the result set. The object value in each cursor ResultRow is an instance of the record class, so views can be built on it. Each object output uses an explicitly unhashable, reused proxy that expires when the cursor advances. Equality on an expired cursor row raises; copying and pickling cursor rows are rejected even before expiry. Components already filled into a view before advancing remain readable on that view, but later component fills raise ExpiredCursorRowError.

Low-level portable protocol

The backend-neutral protocol form remains useful for code that must run on any Searcher implementation. Declare outputs and iterate its plain SearchResult values directly:

search.output(s, "structure")
for (structure,), names in search:
    print(names, structure.formula)

This is the low-level/portable layer; SQL consumers should generally use results().

Neutral portable Store profile

httk.store.Store is intentionally a small, backend-neutral contract: store.searcher() returns a one-query Searcher, which binds one or more backend-defined targets with variable(), receives expressions through add(), and exposes count(), limit/offset, sorting, iteration, and results(). A portable result supports iteration, len(), first(), one(), and scalars(); one() uses the shared NoResultError and MultipleResultsError exceptions. UnsupportedQueryError means that a requested expression is outside a particular backend’s portable subset.

This profile is deliberately what a remote, read-only OPTIMADE store can implement too: it supports a single root endpoint, portable scalar/flat-list filters, named outputs, and result cardinality without making the caller depend on SQLAlchemy or a database dialect. Query code that only needs this profile should depend on httk.store.Store, not SqlStore.

The following are SQL-specific extensions, not portable Store requirements: persisting/fetching frozen dataclasses with save() and fetch(), schema and transaction management, recursive reference storage, lazy SQL rows, ResultColumn.floats()/to_fracvector(), cursor rows, child/reference joins, continuation pages, and SQL’s approximate comparisons for exact rationals. Do not assume those operations exist on a remote or in-memory Store.

A plain comparison on a child field is existential un-negated and set-negating under ~: s.symbols == "O" means “some symbol is O”, ~(s.symbols == "O") means “no symbol is O” (not “some symbol is not O”), agreeing with ~s.symbols.has_any("O"). is_in reads by field kind: on a root field s.formula.is_in("CaTiO3", "NaCl") is plain membership, while on a child field s.symbols.is_in("O", "Ca", "Ti") is the for-all reading — every element must be in the set — exactly the same as has_only.

s.always_true() and s.always_false() are constant conditions on a search variable. They are reserved method names that never resolve to a stored field, and they matter mainly to code that builds filters programmatically: the obvious alternative, a field == field probe, is not NULL-safe — it yields NULL rather than true for a row whose field is NULL, and so silently drops rows.

Result and identity semantics

Search rows are lazy subclasses of the storable class, so isinstance(row, StructureRecord) is true. The parent row is loaded when the row is first used, and fields decode independently as they are accessed. They provide the dataclass-generated compare/hash/repr behavior, honoring each field’s compare, hash, and repr flags, plus the same content-id and save() behavior as the eager record. Classes with custom __eq__ or __hash__ are rejected for lazy rows. Each row also exposes its database sid.

dataclasses.replace(row, ...) creates a new ordinary dataclass instance of the lazy row class and runs validation. Lazy rows intentionally reject copy.copy, copy.deepcopy, and pickling. Search rows bypass the store’s identity cache: two result rows for one sid are not an identity guarantee. fetch() returns lazy rows too (see Lazy records); repeated default fetches of one live sid return the same object.

An object output from an outer join can be None; the result row is retained, not dropped. If a matched sid is deleted before an object output’s lazy row is hydrated, hydration raises StaleResultError; exact scalar projections cannot become stale because their values and _exact companion texts came from the outer SELECT.

Memory and statement cost

The result set materializes a match index that pins the matched sids, raw scalar-output values, and, for exact projections, their _exact companion texts. All of those arrive in the one outer SELECT; there is no second per-chunk exact-value fetch. Hydrated records live in a weak chunk cache of 500 parent sids; live rows pin their own chunk, while re-iteration may rehydrate chunks that are no longer pinned. cursor() limits hydrated record/proxy objects, not this pinned raw result data. A typical full-object pass costs one outer match query plus about one parent query per 500 rows and one batch per touched child-field group per 500 rows — not one SELECT per row. The public store API is append-only, so rows do not disappear during normal use; direct database edits can produce StaleResultError for object outputs.

Exact rationals, approximate comparisons

Rational values (fractions.Fraction, FracScalar, SurdScalar, FracVector tensors) are stored losslessly: a canonical exact text column is the round-trip source of truth, alongside float companion columns used for querying and indexing. Stored values therefore reconstruct exactly at arbitrary precision — but SQL comparisons (and sorting) on rational fields run on the float companions and are documented approximate. Content identity and deduplication always use the exact form.