Rivet.Search

A schema-driven search engine which builds dynamic Ecto queries from user-provided filters, with support for input validation, multi-column ordering, and optional joins.

There are three parts to implementation:

See also:

Creating the Module

Your Schema is defined in your Search module, as shown below. You can implement optional callbacks here, such as c:Rivet.Search.filter_expr/3, to customize how filter inputs are converted into Ecto expressions.

defmodule MyApp.RivetModel.Search do
  schema = [
    status: [type: :atom, list: :in],
    name: [type: :glob],
    inserted_at: [type: :date, order: :only],
    updated_at: [type: :date, order: :only]
  ]

  default_filter = [[n: "name"]]

  use Rivet.Search, schema: schema, default_filters: default_filters

  @impl Rivet.Search
  def filter_expr(:name, ..)

This will generate a search function that supports a map with either :filter or :id as a key (filter taking priority).

Options you can give to the creation of a search model:

Defining the Schema

Each search module defines a schema describing which filters are available and how they are processed. The schema is used during filter preparation to validate incoming values, resolve database columns, configure joins, and determine ordering behavior before building the Ecto query.

The schema is a collection of schemes, one for each searchable column. Each scheme is identified by a key corresponding to the incoming filter's name (n:), with a keyword list specifying its behavior.

Each scheme supports the following options:

Development Note: While Schemes are submitted as a keyword list, internally they are represented as maps.

Input Validation

Input validation and preparation occur in two stages. First, the built-in type validates or converts the incoming value. An optional prep callback can then perform additional validation or transformation.

Every scheme must specify a built-in type, even when using a custom preparation callback.

The built-in types provide straightforward conversions:

Join Callback

A custom join callback (&func/3) is used to construct or reuse Ecto joins when the built-in join behavior is insufficient.

It receives:

(join_key, query, state)

It must return one of:

The optional {:join_key, &func/3} form allows multiple filters to share the same logical join. For example, filters named :vlan and :vlan_id may both reference the :vlan join, avoiding unnecessary duplicate joins.

The callback should preserve existing query bindings and update the filter state as needed to reflect the joins it creates.

Preparation Callback

A preparation callback provides additional validation or transformation of filter values after the built-in type has been processed.

Three callback arities are supported:

Where:

All three arities follow the same return convention:

Preparation callbacks are useful for validation beyond the base type, such as restricting an :atom input to an allowed set of enum values.

Order Callback

A custom order callback (&func/4) provides specialized ordering instructions when standard column ordering is insufficient.

It receives:

(state, direction, filter, spec)

It must return one of:

The order value must be a valid keyword list suitable for Ecto's order_by expression.

Custom ordering is useful for computed fields, expressions involving joined tables, and ordering rules that cannot be represented by a simple column reference.

Frontend Integration

Search Filters

The frontend sends an ordered list of column filters, combining filtering and sorting instructions in a single request. Each filter identifies a column, its optional comparison values and operator, and its sort direction. If multiples of the same column name are specified, only the last is valid.

The position of each filter in the list determines its sorting precedence. For example, the first filter has the highest priority in the resulting ORDER BY clause.

To keep request payloads compact, filter objects use short keys:

* The comparison operators :lt, :le, :gt, and :ge apply to numeric and date values. List comparisons require the corresponding scheme to support list: :in.

Example

The following JavaScript-style request searches by location and serial number, excludes two condition values, and includes additional columns for ordering:

[
  {n: "location", o: "a", v: ["a-location-name"]},
  {n: "serial", v: ["Q53"]},
  {n: "loc"},
  {n: "cond", v: ["failing", "servicing"], x: "no"},
  {n: "sku"}
]

Keys are shown without quotes using JavaScript object syntax. When transmitted as JSON, keys must be quoted.

GraphQL schema

Schema for GraphQL should include:

enum :filter_order do
  value(:a)
  value(:d)
end

enum :filter_operator do
  value(:is)
  value(:no)
  value(:lt)
  value(:le)
  value(:gt)
  value(:ge)
end

input_object :filter_column do
  field(:n, non_null(:string))
  field(:v, list_of(:string))
  field(:o, :filter_order)
  field(:x, :filter_operator, default_value: :is)
end

then it can be used simply as:

  arg(:filter, list_of(:filter_column), default_value: [])