Result materialization and hydration
SearchEngine::Result wraps the raw Typesense response and hydrates each hit into a Ruby object. When a model class is known for the collection, hydrated hits are instances of that class; otherwise, a plain OpenStruct is used.
Why
- Normalize access to metadata:
found,out_of,facets, andraw. - Provide
Enumerableover hydrated hits. - Respect field selection (
include_fields) by hydrating only keys present in the returned document.
Hydration flow
API
Result#found→ number of matching documentsResult#out_of→ number of documents searchedResult#facets→ facet counts (if present)Result#raw→ original parsed response (Hash)Enumerable→ iterate hydrated results (each,map, …)to_a→ duplicate Array of hydrated hits; internal array is frozensize,empty?
Hydration rules
- If a model class is provided, each hit document is assigned to an instance of that class via instance variables named after the document keys. Unknown keys are permitted.
- If no class is provided or collection is unknown, an
OpenStructis created per hit. - Selection is respected implicitly: only keys present in the returned document are set. Missing attributes are not synthesized.
Examples
include_fields, only included fields will be hydrated on each object.
Pluck & selection
pluck(*fields) validates requested fields against the effective selection (include − exclude; exclude wins) before executing. When a requested field is not permitted, it fails fast with an actionable error.
Example:
- When a field is explicitly excluded, the error suggests removing the exclude.
- When includes are present but missing the field, the error suggests
reselect(…)with the current includes plus the requested fields.
ids delegates to pluck(:id). It succeeds when :id is permitted by the effective selection. If :id is explicitly excluded or not part of an include list (when includes are present), it raises InvalidSelection and suggests how to fix it. When includes are empty, all fields are allowed except explicit excludes, so ids works by default.
Pick
pick(*fields) returns the first matching record’s value(s) with the
same selection guardrails as pluck. It returns nil when
no records match.
Example:
Relation materializers and single-request memoization
Materializers onSearchEngine::Relation trigger execution and cache a single SearchEngine::Result per relation instance. The first materializer issues exactly one search; subsequent materializers reuse the cached result. Relation immutability is preserved: chainers return new relations; materializers only populate an internal memo that is invisible to inspect and equality.
API
to_a→ triggers fetch and memoizes; returns hydrated hitseach→ enumerates hydrated hitsfirst(n = nil)→ from the loaded page;noptionallast(n = nil)→ from the loaded page tail; no extra HTTPtake(n = 1)→ head items; whenn == 1returns a single objectpluck(*fields)→ for one field returns a flat array; for many returns array-of-arrays. Falls back to raw documents when model readers are absentpick(*fields)→ returns the first row’s values;nilwhen no matchesids→ convenience forpluck(:id)count→ if loaded, uses memoizedfound; otherwise performs a minimal requestexists?→ if loaded, uses memoizedfound; otherwise performs a minimal requestpages_count→ returns total pages based on memoized totals and the effective per-page size; respects curated counts and falls back to Typesense defaults when neither state nor response metadata provide a value
Minimal request for count / exists?
When the relation has no memo yet, count/exists? issue a minimal search using the same compiled filters/sort and query defaults, but forcing:
per_page = 1,page = 1-
include_fields = “id”
found is returned and no full Result is memoized.
Examples
lastoperates on the currently fetched page. For dataset tail, use explicit sorting/pagination.pluckprefers model readers when available, otherwise reads raw documents for robustness.count/exists?perform a minimal request only when there is no memo; once loaded, they reuse the cachedfound.
Execution & memoization
Materializers call into relation execution, which compiles Typesense body params, merges URL-level cache knobs (config defaults with optional relation overrides), and performs a single client call per relation instance. The raw response is wrapped asSearchEngine::Result and memoized for reuse.
- URL options:
{ use_cache, cache_ttl }originate fromSearchEngine.configand may be overridden per relation viarelation.options(use_cache: …, cache_ttl: …). - Redaction: Event payload params are redacted via
SearchEngine::Observability.redact.