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:
  • 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.
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:
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)

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:
  • 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. 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.
Compiles to:
  • 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.
  • 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

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