Skip to main content
Related: Observability, Field selection, Troubleshooting → Joins, References & JOINs — Deep dive Server‑side joins require lightweight association metadata declared on your model class. This page documents the model‑level DSL, the per‑class registry, how the relation compiles joined selections/filters/sorts, and the instrumentation emitted during compile. It also documents the client‑side fallback for shared‑key joins when a Typesense reference is unavailable. See example: examples/demo_shop/app/controllers/books_controller.rb.

Overview

  • Declare associations on your model with belongs_to :name, has_one :name, and has_many :name (options: collection:, local_key:, foreign_key:; async_ref: for belongs_to).
  • Select joins in queries with Relation#joins(*assocs); names validated against the model’s registry.
  • Use nested include fields for joined collections; where/order can target joined fields using $assoc.field.
  • Observe compile‑time summaries via search_engine.joins.compile without exposing raw literals.

DSL

Declare associations on your model using belongs_to / belongs_to_many / has_one / has_many:
class SearchEngine::Book < SearchEngine::Base
  collection "books"
  attribute :id, :integer
  attribute :author_id, :string  # Reference fields must be :string or [:string]

  # Auto‑resolve keys for a one‑to‑one/one‑to‑many style link
  belongs_to :author

  # For outgoing has‑style links (deterministic)
  has_many :orders, foreign_key: :book_id
end
  • name (Symbol/String): logical association name.
  • collection (Symbol/String): target Typesense collection name (auto‑resolved from name).
  • local_key (Symbol/String): local attribute used as the join key (auto‑resolved; overrideable).
  • foreign_key (Symbol/String): foreign key in the target collection (auto‑resolved; overrideable).
  • async_ref (Boolean, belongs_to / belongs_to_many only): mark the reference asynchronous in schema.

Declaring references

When declaring a belongs_to or belongs_to_many association, the local_key attribute must be declared with type :string or [:string] in your model. Typesense requires reference fields to be strings, and the schema compiler validates this during compilation.
class SearchEngine::Order < SearchEngine::Base
  collection :orders

  # Reference fields must be :string or [:string]
  attribute :customer_id, :string
  attribute :book_ids, [:string]  # Array of references

  belongs_to :customer, local_key: :customer_id
  belongs_to_many :books, local_key: :book_ids
end
If you declare a reference field with a different type (e.g., :integer), schema compilation will raise an error with guidance to update the attribute declaration.

Auto‑resolution rules

  • belongs_to / belongs_to_many (association‑name based):
    • collection: plural of the first argument
    • local_key: singular(name)_ids when the argument is plural; else singular(name)_id
    • foreign_key: singular(name)_id
  • has_one (deterministic):
    • collection: plural of the first argument
    • local_key: <current_singular>_id
    • foreign_key: <current_singular>_id
  • has_many (deterministic):
    • collection: plural of the first argument
    • local_key: <current_singular>_id
    • foreign_key: <current_singular>_ids
Override defaults with explicit collection:, local_key:, or foreign_key:.

Instance association readers (AR-like hop)

Declaring belongs_to, belongs_to_many, has_one, or has_many also defines an instance method with the same name that resolves referenced records using the join config. Example:
module SearchEngine
  class Author < Base
    collection :authors
    attribute :author_id, :integer
    attribute :category_id, :integer, optional: true

    belongs_to :category
    has_many :reviews, foreign_key: :author_ids
  end
end

module SearchEngine
  class Category < Base
    collection :categories
    attribute :category_id, :integer

    has_many :authors, foreign_key: :category_id
    has_many :reviews
  end
end

module SearchEngine
  class Review < Base
    collection :reviews
    identify_by :review_id
    attribute :book_id, :string  # Reference fields must be :string
    attribute :author_ids, [:string], empty_filtering: true  # Array references must be [:string]
    attribute :category_ids, [:string], empty_filtering: true

    belongs_to :authors, async_ref: true
    belongs_to :categories, async_ref: true
  end
end

author = SearchEngine::Author.take
author.category
# => #<SearchEngine::Category ...> or nil

author.reviews
# => #<SearchEngine::Relation [...]]> (chainable)

author.reviews.where(published: true)
# => #<SearchEngine::Relation [...]]>
Behavior:
  • belongs_to (singular name): returns a single record via find_by(foreign_key: local_value); returns nil when local value is nil.
  • belongs_to (plural or when local value is an Array): returns a Relation scoped with where(foreign_key: local_array); empty array yields an empty relation.
  • has_one (any name): returns a single record via find_by(foreign_key: local_value); returns nil when local value is nil.
  • has_many (any name): always returns a Relation scoped with where(foreign_key: local_value); nil local value yields an empty relation.
  • belongs_to_many (any name): always returns a Relation scoped with where(foreign_key: local_value); nil local value yields an empty relation. Use for shared-key one‑to‑many joins.
Notes:
  • Methods are defined at declaration time; resolution uses the per‑class join_for(:name) and SearchEngine.collection_for(cfg[:collection]).
  • Returned Relation is fully chainable (where, order, joins, etc.).
  • Asynchronous belongs_to (async_ref: true) works the same; if the referenced target is missing, the singular call returns nil.
Backlinks: Models · Relation

Shared‑key example (generic)

module SearchEngine
  class Order < Base
    collection :orders
    attribute :order_id, :integer

    # one order → many shipments via shared order_id
    belongs_to_many :shipments, local_key: :order_id, foreign_key: :order_id
  end
end

module SearchEngine
  class Shipment < Base
    collection :shipments
    attribute :order_id, :integer

    # reverse hop: many shipments → one order
    has_one :order, local_key: :order_id, foreign_key: :order_id
  end
end

# Filtering by a joined field
SearchEngine::Order
  .joins(:shipments)
  .where(shipments: { carrier: 'UPS' })

Advanced behavior

  • Client-side fallback can run when Typesense references are missing or shared keys are used.
  • Asynchronous references are available via async_ref: true on belongs_to.
Quoting and type coercion: Joined-field values are coerced using the target model’s attribute types. If a joined field is typed as :string, numeric inputs are converted to strings and quoted in filter_by (e.g., $calculated_products(store_id:=“1070”)).
See References & JOINs — Deep dive for full rules and caveats.

Relation Usage

Use Relation#joins(*assocs) to select join associations on a query. Names are validated against the model’s joins_config and stored in the relation’s immutable state in the order provided. Multiple calls append:
SearchEngine::Book
  .joins(:authors, :orders)
  .where(authors: { last_name: "Rowling" })
  .where(orders: { total_price: 12.34 })
  .order(authors: { last_name: :asc })
  • joins accepts symbols/strings; inputs are normalized to symbols.
  • Unknown names raise SearchEngine::Errors::UnknownJoin with an actionable message that lists available associations.
  • Order is preserved and duplicates are not deduped by default; explicit chaining is honored.
  • For debugging, rel.joins_list returns the frozen array of association names in state.
Backlinks: Overview · Relation · Compiler · Observability · Field Selection

Filtering and Ordering on Joined Fields

With joins applied, you can reference joined collection fields in where and order using nested hashes. Joined left‑hand‑sides render as $assoc.field.
Input (Ruby)Compiled filter_byCompiled sort_by
where(authors: { last_name: "Rowling" })$authors.last_name:=“Rowling”
where(orders: { total_price: 12.34 })$orders.total_price:=12.34
order(authors: { last_name: :asc })$authors.last_name:asc
order(“$authors.last_name:asc”)$authors.last_name:asc
Notes:
  • Base fields continue to work unchanged (e.g., where(active: true)).
  • Mixed base and joined predicates interleave as usual; the compiler preserves grouping semantics.
  • Raw order strings are accepted as‑is; ensure you supply valid Typesense fragments.

Nested field selection for joined collections

You can select fields from joined collections using a nested Ruby shape. These compile to Typesense include_fields with $assoc(field,…) segments.
# Full relation example
SearchEngine::Book
  .joins(:authors)
  .include_fields(:id, :title, authors: [:first_name, :last_name])
Compiles to:
$authors(first_name,last_name),id,title
  • Input types: mix base fields (:id, “title”) and nested hashes (authors: [:first_name, :last_name]).
  • Merging: multiple calls merge and dedupe. First mention wins ordering; later calls append only new fields.
# Merged across calls
SearchEngine::Book
  .include_fields(:id, authors: [:a])
  .include_fields(:title, authors: [:b, :a])
# => "$authors(a,b),id,title"
  • Ordering policy: nested $assoc(…) segments are emitted first in association first-mention order, then base fields.
  • Validation: association keys are validated against klass.joins_config (UnknownJoin on typos). Calling .joins(:assoc) before selecting nested fields is recommended; the compiler will still emit $assoc(…) even if joins wasn’t chained yet.

End-to-end example with filters and sort

rel = SearchEngine::Book
  .joins(:authors)
  .include_fields(authors: [:first_name])
  .where(authors: { last_name: "Rowling" })
  .order(authors: { last_name: :asc })
rel.to_typesense_params
# => { q: "*", query_by: "name, description", include_fields: "$authors(first_name)", filter_by: "$authors.last_name:=\"Rowling\"", sort_by: "$authors.last_name:asc" }
Internals: the returned params also include a reserved :_join key with join context for downstream components. See Compiler for the exact shape.

Common pitfalls

  • Declare every association on the model before using .joins(:assoc).
  • Use only single-hop paths like $assoc.field.
  • Keep joined field names aligned with the target model attributes.

Deep dives