Skip to main content
Related: Relation, Materializers, Upsert

Models and the Collection Registry

SearchEngine provides a minimal model layer to prepare for future hydration of Typesense documents into Ruby objects. A thread-safe registry maps Typesense collection names to model classes.
  • Registry: SearchEngine.register_collection!(name, klass) and SearchEngine.collection_for(name)
  • Base class: SearchEngine::Base with collection and attribute macros

Declare a model

class SearchEngine::Book < SearchEngine::Base
  collection :books

  # id is reserved and is not declared via `attribute`
  attribute :title, :string
end

Lookup

SearchEngine.collection_for(:books)
#=> SearchEngine::Book

Convenience: find by id

SearchEngine::Book.find("BOOK-1")
# same as:
SearchEngine::Book.find_by(id: "BOOK-1")
Note: .find is available on the model class only; it is not delegated to relations.

Errors

  • Unknown collection: raises ArgumentError with a helpful message
  • Duplicate registration: re-registering the same mapping is a no-op; attempting to map a collection to a different class raises ArgumentError

Document identity (identify_by)

Every Typesense document must include a unique String id. The gem computes this automatically per record and injects it during indexing.
When source is not :active_record, you must define identify_by. SQL and lambda sources yield rows that do not expose id, and the mapper ignores any id returned from map. See Troubleshooting for missing id errors during indexing.
  • Default: record.id.to_s
  • Override with identify_by:
class SearchEngine::Book < SearchEngine::Base
  collection :books

  # Use a method name on the source record
  identify_by :isbn

  # or a custom proc
  # identify_by ->(r) { "#{r.publisher_id}-#{r.id}" }

  index do
    source :active_record, model: ::Book
    map do |r|
      { title: r.title } # any provided :id here will be ignored
    end
  end
end
Rules and behavior:
  • attribute :id, … is invalid and raises. Use identify_by.
  • Any id returned from index -> map is ignored.
  • Inspect output renders id first regardless of whether it was declared.
  • Precedence order: When creating/upserting documents, the id is resolved as: (1) explicit :id attribute provided, (2) identify_by strategy result, (3) record.id.to_s fallback (ActiveRecord sources only).
  • String coercion: All IDs are coerced to strings internally (Typesense requirement). The gem handles this automatically.

System field: doc_updated_at

  • Always injected on create/upsert/update (epoch seconds).
  • If declared via attribute, its type is coerced to int64 during schema compilation.
  • Hydration converts it to Time (uses Time.zone when present).
  • Cannot be disabled; Typesense requires it for tracking.

Creating documents

Use Model.create(attrs) to insert a single document into the backing collection and get a hydrated instance back.
class SearchEngine::Book < SearchEngine::Base
  collection :books
  attribute :title, :string
  identify_by :isbn
end

book = SearchEngine::Book.create(title: "The Ruby Guide", isbn: "978-1234567890")
#=> #<SearchEngine::Book @id="978-1234567890" @title="The Ruby Guide" @doc_updated_at=...>
Notes:
  • Validates required fields (respects optional:). Unknown fields are rejected when mapper.strict_unknown_keys is enabled.
  • Automatically sets doc_updated_at and hidden flags (*_empty, *_blank) when present in schema.
  • :id may be provided explicitly; otherwise it is computed via identify_by. If absent, Typesense may generate one.
  • By default, targets the alias (logical) collection; pass into: “physical_name” to override.

Updating documents

Use record.update(attrs) to partially update fields of a hydrated record in Typesense.
book = SearchEngine::Book.find("978-1234567890")
book.update(title: "New Title", price: 19.99)
# => 1
Notes:
  • Updates are partial; only provided fields are sent.
  • Requires the record to have an id (hydrated or computable).
  • Returns 1 on success, 0 on failure (failures are logged).
  • Accepts into:, partition:, timeout_ms:, and cascade: options.

Mapping source data to model instances (.from)

Use Model.from(data, mode: :instance) to map source data (ActiveRecord instances or SQL results) onto the model’s schema and return hydrated instances or hashes. This uses the same mapping logic defined in your index DSL but does not make any Typesense API calls. The method accepts input corresponding to your configured source type:
  • :active_record source: Accepts an ActiveRecord instance or an Array of instances. Output preserves input shape (single instance → single instance, array → array).
  • :sql source: Accepts a SQL String, executes it internally to fetch rows, and always returns an Array of results (even when a single row is returned).

ActiveRecord source examples

class SearchEngine::Book < SearchEngine::Base
  collection :books
  attribute :title, :string
  attribute :author_id, :integer

  index do
    source :active_record, model: ::Book
    map do |r|
      { title: r.title, author_id: r.author_id }
    end
  end
end

# Single instance → single instance
book = SearchEngine::Book.from(::Book.first)
#=> #<SearchEngine::Book @id="1" @title="The Ruby Guide" @author_id=123>

# Array → array
books = SearchEngine::Book.from([::Book.first, ::Book.second])
#=> [#<SearchEngine::Book ...>, #<SearchEngine::Book ...>]

SQL source examples

class SearchEngine::Book < SearchEngine::Base
  collection :books
  attribute :title, :string
  attribute :author_id, :integer
  identify_by ->(row) { row['id'] }

  index do
    source :sql, sql: "SELECT id, title, author_id FROM books"
    map do |row|
      { title: row['title'], author_id: row['author_id'] }
    end
  end
end

# SQL always returns an array, even for single-row results
books = SearchEngine::Book.from("SELECT id, title, author_id FROM books WHERE id = 1")
#=> [#<SearchEngine::Book @id="1" @title="The Ruby Guide" @author_id=123>]

Hash mode

Pass mode: :hash to return HashWithIndifferentAccess documents instead of model instances. This is useful when you need raw hash data or want to avoid hydration overhead.
# Returns HashWithIndifferentAccess with both symbol and string keys
doc = SearchEngine::Book.from(::Book.first, mode: :hash)
#=> {"id"=>"1", :id=>"1", "title"=>"The Ruby Guide", :title=>"The Ruby Guide", ...}

# Access with either key type
doc[:title]   #=> "The Ruby Guide"
doc['title']  #=> "The Ruby Guide"

Behavior and validation

  • Mapping: Uses the same map block and schema validation as defined in your index DSL.
  • No Typesense calls: This method only performs local mapping and validation; it does not interact with Typesense.
  • Schema validation: Validates mapped documents against the compiled schema (required fields, types, etc.).
  • Shape preservation: For ActiveRecord sources, single input → single output, array input → array output. For SQL sources, output is always an array.
  • Source requirement: The model must have a source defined in its index DSL (:active_record or :sql). Models using only partition_fetch without a source declaration will raise an error.

Error cases

  • Missing mapper: Raises SearchEngine::Errors::InvalidParams if no map block is defined.
  • Missing source: Raises SearchEngine::Errors::InvalidParams if no source is defined in the index DSL.
  • Invalid mode: Raises SearchEngine::Errors::InvalidOption if mode is not :instance or :hash.
  • Type mismatch: For ActiveRecord sources, raises SearchEngine::Errors::InvalidParams if input is not an instance of the configured model class.
  • Unsupported source: Raises SearchEngine::Errors::InvalidOption for source types other than :active_record or :sql.
Backlinks: Indexer, Schema

Custom instance methods

Hydrated results are instances of your model class, so any instance methods you define are available on results.
class SearchEngine::Publisher < SearchEngine::Base
  collection :publishers

  attribute :category_id, :integer

  index do
    # ... indexation logic ...
  end

  def category_name
    @category_name ||= ::Category.find_by(id: category_id).name
  end
end

SearchEngine::Publisher.first.category_name
# => "Technical Publishing"
Notes:
  • Declared attributes get attr_readers automatically (e.g., category_id).
  • Boolean attributes automatically get a question-mark alias method (e.g., available? for attribute :available, :boolean).
  • See also: Relation, Materializers

Instance association readers (joins)

When you declare belongs_to, has_one, or has_many on a model, an instance method with the same name is defined to resolve referenced records:
  • belongs_to :authorbook.author returns a single record or nil.
  • has_many :ordersbook.orders returns a chainable Relation.
See details and examples in Joins.

Inheritance

Attributes declared in a parent class are inherited by subclasses. A subclass may override an attribute by redeclaring it:
class SearchEngine::Item < SearchEngine::Base
  attribute :name, :string
end

class SearchEngine::Book < SearchEngine::Item
  attribute :name, :text # overrides only for Book
end

Flow (registry lookup)

See also: Materializers.

Thread-safety and reload-friendliness

The collection registry uses a copy-on-write Hash guarded by a small Mutex. Reads are lock-free and writes are atomic, which makes it safe under concurrency and friendly to Rails code reloading in development. See also: Relation.

Nested fields (object / object[])

When you need to store nested objects:
  • Declare the base attribute as :object (single) or [:object] (array of objects).
  • Prefer declaring subfields inline via the nested: option on attribute.
Rules:
  • If base is :object, subfields compile to scalar Typesense types (e.g., float).
  • If base is [:object], subfields compile to array Typesense types (e.g., float[]).
  • The schema compiler auto-enables enable_nested_fields at collection level when any object/object[] is present.
Example (inline nested):
class SearchEngine::Book < SearchEngine::Base
  collection :books

  attribute :prices, [:object], nested: {
    current_price: :float,
    list_price: :float,
    discount_percent: :float,
    minimum_quantity: :integer,
    price_type: :string,
    start_date: :string,
    end_date: :string
  }
end
Backlinks: Schema, Home

Model scopes (ActiveRecord-like)

Define reusable, chainable scopes on your model. Scopes are evaluated against a fresh Relation (all) and must return a SearchEngine::Relation.
class SearchEngine::Book < SearchEngine::Base
  collection :books

  scope :published, -> { where(published: true) }
  scope :featured, -> { where(published: true, featured: true) }
  scope :by_publisher, ->(publisher_id) { where(publisher_id: publisher_id) }
end

# Usage
SearchEngine::Book
  .published
  .featured
  .by_publisher(params[:publisher_id])
  .search(params[:search_query])
Notes:
  • Scopes are class methods created by scope :name, -&gt;{ ... }.
  • The block is executed with self set to a fresh Relation; return a Relation (or nil to fallback to all).
  • You can pass parameters to scopes (e.g., by_store(id)).

Per‑model default query_by

You can set a default query_by for a collection at the model level. This value participates in the usual precedence: relation options override model default, which overrides global config (SearchEngine.config.default_query_by).
class SearchEngine::Book < SearchEngine::Base
  collection :books
  query_by :title, :description
  # also accepts a comma string: query_by "title, description"
end

# Effective precedence inside compile
# relation.options(query_by: ...) > model.query_by > config.default_query_by
Return value on write is self to allow macro chaining; when called with no arguments it returns the canonical comma‑separated String or nil if unset. Backlinks: Configuration, Relation

Attribute options reference

Attributes accept additional options to influence schema and query behavior.
class SearchEngine::Article < SearchEngine::Base
  collection :articles

  attribute :title, :string, sort: true, optional: false, infix: true
  attribute :tags,  [:string], empty_filtering: true
  attribute :name,  :string, locale: "en"
  attribute :category, :string, facet: true
  attribute :published, :boolean
end

Boolean attribute question-mark aliases

When you declare a boolean attribute, a question-mark alias method is automatically created for convenient predicate-style access:
class SearchEngine::Article < SearchEngine::Base
  collection :articles

  attribute :published, :boolean
  attribute :featured, :boolean
end

article = SearchEngine::Article.from_document(
  id: '1',
  published: true,
  featured: false
)

# Both forms work identically
article.published   # => true
article.published? # => true

article.featured   # => false
article.featured?  # => false
The question-mark alias is a simple method alias; it returns the same value as the regular reader with no additional logic. Only attributes declared with type :boolean (not array types like [:boolean]) receive this alias. Supported options and constraints:
  • locale: String — only for :string or [:string] attributes.
  • sort: true|false — mark field sortable in schema.
  • optional: true|false — marks field optional; enables <name>_blank flag in schema.
  • infix: true|false — enable Typesense infix for this field in schema.
  • empty_filtering: true|false — only for array types (e.g., [:string]); enables <name>_empty hidden field for safe empty‑array filtering.
  • facet: true|false — mark field as facetable in schema for faceted search/filtering.
  • index: true|false — when false, the field is omitted from the compiled Typesense schema. You may still send the field in documents; Typesense stores it and returns it with hits, but it is not indexed in memory. Nested subfields under an unindexed base object are also omitted from schema.
Example (display‑only attribute that is not indexed):
class SearchEngine::Article < SearchEngine::Base
  collection :articles

  attribute :slug, :string
  attribute :headline, :string
  attribute :body_html, :string, index: false # stored & returned, but not indexed
end

# Articles#create/upsert may include :body_html, and hydrated results render it in console output/inspect like any other declared attribute.
See also how these options appear in the compiled schema and diffs in Schema (keys: locale, sort, optional, infix).

Declaring nested subfields with nested

As an alternative to inline nested: on attribute, you can declare subfields after the base attribute is defined as :object or [:object]:
attribute :prices, [:object]
nested :prices,
  current_price: :float,
  price_type: :string
When the base is :object, nested subfield types are scalar (e.g., :float). When the base is [:object], nested subfield types are arrays (e.g., [:float]). Backlinks: Schema, Field Selection, Indexer