Joins configuration
Copy MarkdownThe joins configuration option enables you to build facets from values in related tables instead of limiting them to columns on the source table itself.
A facet's value can come from a table one join away. For example, if articles references authors, the joins option makes author fields available as facets. It can also come from a table several joins away, reached through a chain of intermediate tables.
joins: [
%{
table: "roles",
match: "roles.id",
to: "authors.role_id"
}
]Each entry in joins describes one step in that chain: which table to bring in, and how it connects to a table already in the chain.
By listing several steps, you can follow a path of any length from the source table to the table holding the facet value.
A common case is a many-to-many relationship:
articles→article_categories→categories
Refine keeps the facet data it reads from the joined tables automatically up to date. Inserts, updates, and deletes anywhere along the chain - including changes to the intermediate tables that link everything together - are tracked and reflected in the facet data. This covers renaming a joined row, attaching or detaching a related row, and deleting a joined row.
Because tracking happens at the database level, it applies whether changes are made through Ecto, raw SQL, or association casting.
See also the limitations section below.
Join options
The joins entry map can be read as:
- Join
table - ... matching its
matchcolumn (on the entry's own table) - ... to the
tocolumn (on the source table or an earlier join) - ...
wherevalue is true or false (optional SQL filter, see Filtering ↓)
Filtering a joined table
Use where to apply an additional condition to a joined table - for example, to exclude soft-deleted rows.
It applies an SQL filter to the join, appended as an additional AND condition. The filter is raw SQL,
appended as an AND to the join condition. So it's written in database terms (= true, <> 'archived', IS NOT NULL), not Ecto terms.
The filter may only reference the table introduced by its own entry.
%{
table: "categories",
match: "categories.id",
to: "article_categories.category_id",
where: "categories.active"
}Other where filters:
where: "categories.name <> 'editorial'"
where: "article_categories.is_primary = true"
where: "tags.label IS NOT NULL"
where: "categories.created_at >= '2024-01-01'"Directly related tables
When the source table has a foreign key to the joined table, a single join is enough.
Steps in this example:
authors→roles→ read role'sname
%{
facets_table: "authors_facets",
source_table: "authors",
add_identity_column_if_not_exists: true,
identity_column: "identity",
facets: [
%{
facet_name: "role_name",
join_table: "roles",
value_column: "name"
}
],
joins: [
%{
table: "roles",
match: "roles.id",
to: "authors.role_id"
}
]
}Intermediate tables
When the joined table is reached through a link table - for example a many-to-many relationship - declare each step as its own entry.
Steps in the example below:
articles→article_categories→categories→category_texts→ read text'stitle
%{
facets_table: "articles_facets",
source_table: "articles",
add_identity_column_if_not_exists: true,
identity_column: "identity",
facets: [
%{
facet_name: "category_texts",
join_table: "category_texts",
value_column: "title"
}
],
joins: [
%{
table: "article_categories",
match: "article_categories.article_id",
to: "articles.id"
},
%{
table: "categories",
match: "categories.id",
to: "article_categories.category_id"
},
%{
table: "category_texts",
match: "category_texts.category_id",
to: "categories.id"
}
]
}In this example:
- The first entry brings in
article_categories, matching itsarticle_idtoarticles.idon the source. - The second entry brings in
categories, matching itsidto thecategory_idcolumn onarticle_categoriesfrom the first entry. - The third entry brings in
category_texts, matching itscategory_idtocategories.id.
Each new entry extends the chain by connecting one of its own columns back to a column already in scope.
Qualified table names
Every reference to a table in a non-default schema must include the schema prefix. Tables in the public schema are written without a prefix.
Add the non-default schema prefix to these options:
facetsjoin_table
joinstablematchto
Example:
config = %{
facets_table: facets_table,
source_table: source_table,
add_identity_column_if_not_exists: true,
identity_column: "identity",
facets: [
%{
facet_name: "category_texts",
join_table: "classifications.category_texts", # <~~~
value_column: "title"
}
],
joins: [
%{
table: "article_categories",
match: "article_categories.article_id",
to: "articles.id"
},
%{
table: "classifications.categories", # <~~~
match: "classifications.categories.id", # <~~~
to: "article_categories.category_id"
},
%{
table: "classifications.category_texts", # <~~~
match: "classifications.category_texts.category_id", # <~~~
to: "classifications.categories.id" # <~~~
}
]
}Limitations and requirements
Joined facets are kept up to date using database triggers installed on both the source and joined tables. This introduces a number of requirements and constraints.
Joined table should use cascading deletes
Whether deleting a row from a joined table correctly updates facets depends on the foreign key relationships between the tables in the join chain.
For many-to-many joins, the foreign key from the join (link) table to the joined table should use cascading deletes.
In an Ecto migration this is configured using references(..., on_delete: :delete_all):
add :category_id, references(:categories, on_delete: :delete_all)Without cascading deletes, removal of a referenced row in the joined table will be blocked by the database. As a result, facet updates cannot occur because the deletion itself does not take place.
Joins must use single-column foreign keys
Each join connects exactly one column to one column. Composite (multi-column) foreign keys are not supported. If a join's underlying foreign key spans more than one column, creating the facets table fails with an error.
Joins connect tables by matching equal columns
Each join links two tables by pointing at a column in each that holds the same value.
In the example below, the join connects article_categories to articles wherever
an article_categories row's article_id equals an articles row's id - the standard way a foreign key relates two tables.
match: "article_categories.article_id",
to: "articles.id"This is the only kind of connection a join can express: one column equals another.
You can't join on a range, a comparison, or a more complex condition (for example, "where the article's date
falls within the category's active period"). If you need to narrow which rows a join includes,
use a where filter on the join rather than trying to express it in the connection itself.
Chain depth
Joins through intermediate tables have been tested up to three steps:
- source → intermediate → intermediate → leaf
Deeper chains use the same mechanism but are not yet covered by tests.
Testing requires committed data
Because tracking happens through database triggers, tests that exercise incremental updates need the sandbox in :auto mode -
the triggers must see committed data, which a rolled-back transaction (:manual mode) does not provide. See the testing guide
for the cleanup pattern this requires.