SearchAsh.Source (search_ash v0.4.2)

Copy Markdown View Source

Ash extension that mirrors a resource into a SearchAsh.GlobalIndex so it shows up in the unified global search.

defmodule MyApp.Sales.BonDeCommande do
  use Ash.Resource,
    domain: MyApp.Sales,
    data_layer: AshPostgres.DataLayer,
    extensions: [SearchAsh.Source]

  searchable do
    index MyApp.Search.Document
    source_type :bon_de_commande
    fields [:numero, :client_nom, :description]
    language_attribute :language
    label_field :numero
    archived :deleted_at         # optional — truthy value marks the row archived
  end

  # ... attributes + create/update/destroy actions ...
end

Choosing the language

Each indexed row is stemmed in one language, resolved one of two ways — pick whichever fits the resource:

  • language_attribute :language (the default) reads the language per row from that attribute, so one resource can hold rows in many languages.
  • language :fr fixes one language for every row of the resource. Use it for a mono-language resource, which then needs no language attribute at all.

The two are mutually exclusive, and setting neither is only valid when the resource actually has a :language attribute — a compile-time verifier enforces both rules.

searchable do
  index MyApp.Search.Document
  source_type :page_statique
  fields [:titre, :corps]
  language :fr                 # no :language attribute on this resource
end

The extension sets require_atomic? false on the update/destroy actions it augments (stemming happens in Elixir, so the sync can't be expressed as an atomic SQL statement), so you don't set it yourself. Because the sync writes only to the separate index table (never to a source attribute), it is atomic-compatible: Ash.bulk_create/bulk_update/bulk_destroy keep the index in sync with no strategy: option required.

On create/update it upserts a stemmed document into the index (tenant-aware). archived derives the index flag from a source attribute's truthiness (a boolean, or a deleted_at timestamp) or a record -> boolean function; :global_search hides archived rows by default. On destroy, on_destroy either removes the row (:remove, default) or keeps it archived (:archive, for AshArchival-style soft deletes).

Backfill existing rows with SearchAsh.reindex(MyApp.Sales.BonDeCommande).

fields reads attributes of the record itself. To index text that lives on related records — "which orders mention tomatoes?" — combine load (an Ash load statement run before the document is built) with extra_text (a function deriving text from the loaded record):

searchable do
  index MyApp.Search.Document
  source_type :commande
  fields [:numero]
  label_field :numero
  load [:lignes]
  extra_text fn commande -> Enum.map(commande.lignes, & &1.designation) end
end

extra_text is repeatable, and each entry carries its own rank class (:d by default, as body text). That is how two things you derive can matter differently:

extra_text fn commande -> Enum.map(commande.lignes, & &1.designation) end
extra_text &date_in_words(&1.updated_at), weight: :b
extra_text &date_in_words(&1.inserted_at), weight: :c

A date spelled out is just more text — the same pipeline stems both sides, so a search box query like juillet finds it, with no support needed from the extension. Keep it next to a typed index_attribute for the same date: one serves the search box, the other range filters and sorting.

The load runs at the single indexing choke point, so every path gets it: the sync on writes, SearchAsh.reindex/2, SearchAsh.reindex_one/3. Because the extension cannot tell what extra_text reads, every update of the resource re-indexes it (no changed-field short-circuit).

The staleness contract. The sync fires on writes to this resource. A direct write to the related resource — editing a line without touching its order — changes what extra_text would return, but nothing observable happened on the order, so its index row keeps the old text. This is the same class of gap as "Writes that bypass Ash" below, with the same remedies: call SearchAsh.reindex_one/3 on the parent after such a write, or add a change on the related resource that touches its parent. The staleness is cosmetic (a search misses or over-matches until reconciled) — which is exactly why this exists for content and must never be used to mirror authorization data into the index.

Filtering and sorting: index_attribute

fields decides what is searched. index_attribute decides what can be filtered and sorted on: a date, a reference, an amount, a foreign key. Declare the column on the index resource (a source cannot add a column to a resource it does not own), then say here how to fill it:

index_attribute :document_date, :date_emission              # from an attribute
index_attribute :montant, &(&1.lignes |> Enum.map(fn l -> l.total end) |> Enum.sum())

An attribute name is worth preferring: the sync then knows to re-index when only that attribute changes. A function is opaque, so every write rebuilds the document.

These columns are ordinary Ash attributes on the index, so nothing new is needed to use them:

|> Ash.Query.filter(document_date >= ^from and document_date <= ^to)
|> Ash.Query.sort(document_date: :desc)

Many dates, one axis

A business document has several dates — issued, delivered, due, created — and each entity type names them differently. Resist giving each its own index column: what a results page needs is one comparable axis, so "most recent first" means something across mixed entity types.

So point every source at the same column, from whatever attribute means "the date this document is from" for it:

# facture
index_attribute :document_date, :date_emission

# bon de livraison
index_attribute :document_date, :date_livraison

# produit — no business date of its own
index_attribute :document_date, &DateTime.to_date(&1.inserted_at)

Add a second column only when you genuinely filter on a second axis (an invoice due date, say) — and expect it to be NULL for the source types that have no such date, which is also how they sort.

Only what is derived from the record

These columns are rewritten on every sync, which is exactly what keeps them honest. That also marks their limit: never mirror an authorization fact here. "This document belongs to client 42" is content and re-syncs with the document; "user 7 may read this" changes on its own, nothing triggers a re-index, and a stale row of that kind is a security incident rather than a cosmetic one. Filter on the content column and keep the rule in your application.

Writes that bypass Ash

The sync above is a Ash.Resource.Change, so it only runs when Ash builds a changeset. A write that goes straight to the database — a raw Repo.query!, a SQL cascade updating a denormalized column across rows, a restore — never reaches it, and the index silently keeps the old document. Reconcile the affected records afterwards with SearchAsh.reindex_one/3, which re-reads each one and works out whether to re-index or remove it — or sweep a whole source for index rows whose record is gone with SearchAsh.prune/2.

Composite primary keys

A row is identified in the index by its source_id, built by joining the primary key parts with ":". With a single-column key (the usual case — a uuid_primary_key) this is exact. With a composite key of two or more string columns, the join is ambiguous: {"a:b", "c"} and {"a", "b:c"} both render "a:b:c" and would share one index row, one masking the other. Integer parts, or a single-column key, cannot collide. If you index a resource whose primary key is several string columns and those values may themselves contain ":", that is a limitation to be aware of — it does not affect single-column or integer keys.

Summary

Functions

searchable(body)

(macro)