Packages

Automatically adds AshPostgres custom indexes for `belongs_to` relationships in Ash resources.

Current section

Files

Jump to
Raw

lib/transformer.ex

defmodule AshPostgresBelongsToIndex.Transformer do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer
alias Ash.Resource.Relationships.BelongsTo
def after?(Ash.Resource.Transformers.BelongsToAttribute), do: true
def after?(_), do: false
def transform(dsl_state) do
except_list = Transformer.get_option(dsl_state, [:postgres_belongs_to_index], :except, [])
multitenant_attr = Transformer.get_option(dsl_state, [:multitenancy], :attribute)
manual_references = Transformer.get_entities(dsl_state, [:postgres, :references])
dsl_state
|> get_belongs_toes()
|> reject_excluded_relationships(except_list)
|> add_missing_indexes(dsl_state, manual_references, multitenant_attr)
|> then(&{:ok, &1})
end
def get_belongs_toes(dsl_state) do
dsl_state
|> Transformer.get_entities([:relationships])
|> Enum.filter(fn
%BelongsTo{} -> true
%{} -> false
end)
end
defp reject_excluded_relationships(belongs_tos, except_list) do
Enum.reject(belongs_tos, fn %BelongsTo{name: name} -> name in except_list end)
end
defp has_indexed_manual_reference?(relationship_name, manual_references) do
Enum.any?(manual_references, fn ref ->
ref.relationship == relationship_name && ref.index? == true
end)
end
# A custom index covers the given fields when its effective fields start with
# them (leftmost prefix rule), e.g. [:fk_id, :created_at] covers [:fk_id].
# A partial index only counts when its condition is implied by the lookups we
# care about: FK lookups are equality on the last field, which implies
# "last_field IS NOT NULL". Any other predicate may exclude matching rows.
defp has_custom_index_on?(dsl_state, effective_fields, multitenant_attr) do
dsl_state
|> Transformer.get_entities([:postgres, :custom_indexes])
|> Enum.any?(fn idx ->
idx |> effective_index_fields(multitenant_attr) |> List.starts_with?(effective_fields) &&
(is_nil(idx.where) || idx.where == "#{List.last(effective_fields)} IS NOT NULL")
end)
end
defp effective_index_fields(idx, multitenant_attr) do
fields = idx.fields || []
case {multitenant_attr, idx.all_tenants?} do
{nil, _} -> fields
{_, true} -> fields
{tenant_attr, _} -> Enum.uniq([tenant_attr | fields])
end
end
defp add_missing_indexes(belongs_tos, dsl_state, manual_references, multitenant_attr) do
# First pass: add composite indexes for non-tenant relationships
dsl_state =
Enum.reduce(belongs_tos, dsl_state, fn belongs_to, acc ->
add_composite_index_for_relationship(belongs_to, acc, manual_references, multitenant_attr)
end)
# Second pass: add single-column indexes where needed
Enum.reduce(belongs_tos, dsl_state, fn belongs_to, acc ->
add_single_column_index_for_relationship(belongs_to, acc, multitenant_attr)
end)
end
defp add_composite_index_for_relationship(
%BelongsTo{name: name, source_attribute: source_attr},
dsl_state,
manual_references,
multitenant_attr
) do
# Skip composite index if source_attr == tenant_attr (would be redundant [:company_id, :company_id])
if source_attr == multitenant_attr do
dsl_state
else
has_manual_ref = has_manual_reference?(name, manual_references)
ensure_composite_index(
dsl_state,
name,
source_attr,
multitenant_attr,
has_manual_ref,
manual_references
)
end
end
defp add_single_column_index_for_relationship(
%BelongsTo{source_attribute: source_attr},
dsl_state,
multitenant_attr
) do
ensure_single_column_index(dsl_state, source_attr, multitenant_attr)
end
defp ensure_composite_index(
dsl_state,
name,
source_attr,
multitenant_attr,
has_manual_ref,
manual_references
) do
composite_fields = build_index_fields(source_attr, multitenant_attr)
already_has_composite =
has_custom_index_on?(dsl_state, composite_fields, multitenant_attr) ||
has_indexed_manual_reference?(name, manual_references)
if already_has_composite do
dsl_state
else
case has_manual_ref do
true -> add_custom_index(dsl_state, composite_fields, [])
false -> add_indexed_reference(dsl_state, name)
end
end
end
defp ensure_single_column_index(dsl_state, _source_attr, nil), do: dsl_state
defp ensure_single_column_index(dsl_state, source_attr, tenant_attr) do
# Only create single-column index if no existing index covers this column
# as the leftmost field (which can satisfy FK lookups via prefix rule)
if has_index_starting_with?(dsl_state, source_attr, tenant_attr) do
dsl_state
else
add_custom_index(dsl_state, [source_attr], all_tenants?: true)
end
end
defp has_index_starting_with?(dsl_state, field, multitenant_attr) do
has_custom_index_on?(dsl_state, [field], multitenant_attr) ||
has_indexed_reference_starting_with?(dsl_state, field, multitenant_attr)
end
defp has_indexed_reference_starting_with?(dsl_state, field, multitenant_attr) do
# Indexed references create composite indexes starting with tenant_attr (if multitenant)
# or with the FK column (if non-multitenant)
case multitenant_attr do
nil ->
false
tenant_attr ->
# If looking for tenant_attr and there's any indexed reference, it will start with tenant_attr
field == tenant_attr &&
dsl_state
|> Transformer.get_entities([:postgres, :references])
|> Enum.any?(& &1.index?)
end
end
defp has_manual_reference?(relationship_name, manual_references) do
Enum.any?(manual_references, fn ref -> ref.relationship == relationship_name end)
end
defp add_custom_index(dsl_state, fields, opts) do
{:ok, index} =
Transformer.build_entity(
AshPostgres.DataLayer,
[:postgres, :custom_indexes],
:index,
Keyword.merge([fields: fields], opts)
)
Transformer.add_entity(dsl_state, [:postgres, :custom_indexes], index, type: :append)
end
defp add_indexed_reference(dsl_state, relationship_name) do
{:ok, reference} =
Transformer.build_entity(
AshPostgres.DataLayer,
[:postgres, :references],
:reference,
relationship: relationship_name,
index?: true
)
Transformer.add_entity(dsl_state, [:postgres, :references], reference, type: :append)
end
defp build_index_fields(source_attr, nil), do: [source_attr]
defp build_index_fields(source_attr, tenant_attr), do: [tenant_attr, source_attr]
end