Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## 0.5.0 (2026-07-19)

### Improvements

- Nullable `belongs_to` relationships now use **partial indexes** that exclude
`NULL` rows: generated references get `index_where: :not_nil`, and indexes added
via `custom_indexes` get `where: "fk_id IS NOT NULL"`. FK lookups are equality
matches which always imply `IS NOT NULL`, so the smaller index serves them fully.
Relationships with `allow_nil? false` keep full indexes.
- On multitenant resources, the tenant attribute now gets its own single-column
index whenever every other FK index is partial: a partial index on another FK
excludes rows where that FK is `NULL`, so it cannot serve tenant-only lookups.

### Dependencies

- Additionally requires `index_where` support on references
(ash-project/ash_postgres#795, not yet in a hex release — the dependency stays on
GitHub main).

### Upgrading from 0.4.0

Running `mix ash.codegen` will regenerate indexes for nullable relationships as
partial ones (`WHERE fk_id IS NOT NULL`). This is intentional — the smaller
indexes serve FK lookups fully.

## 0.4.0 (2026-07-19)

### Fixes
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Add `ash_postgres_belongs_to_index` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:ash_postgres_belongs_to_index, "~> 0.4.0"}
{:ash_postgres_belongs_to_index, "~> 0.5.0"}
]
end
```
Expand Down Expand Up @@ -38,16 +38,20 @@ defmodule Post do
end
```

For the example above, the following index will be generated:
For the example above, the following index will be generated. Nullable references use partial
indexes (`index_where: :not_nil`) that exclude `NULL` values:

```elixir
postgres do
references do
reference :user, index?: true
reference :user, index?: true, index_where: :not_nil
end
end
```

Relationships configured with `allow_nil? false` use a full reference index instead. Indexes
added via `custom_indexes` (see below) get the equivalent `where: "user_id IS NOT NULL"`.

## Conflict detection

Indexes are only added when the FK is not already covered:
Expand Down
60 changes: 42 additions & 18 deletions lib/transformer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ defmodule AshPostgresBelongsToIndex.Transformer do
end

defp add_composite_index_for_relationship(
%BelongsTo{name: name, source_attribute: source_attr},
%BelongsTo{name: name, source_attribute: source_attr, allow_nil?: allow_nil?},
dsl_state,
manual_references,
multitenant_attr
Expand All @@ -93,17 +93,18 @@ defmodule AshPostgresBelongsToIndex.Transformer do
source_attr,
multitenant_attr,
has_manual_ref,
manual_references
manual_references,
allow_nil?
)
end
end

defp add_single_column_index_for_relationship(
%BelongsTo{source_attribute: source_attr},
%BelongsTo{source_attribute: source_attr, allow_nil?: allow_nil?},
dsl_state,
multitenant_attr
) do
ensure_single_column_index(dsl_state, source_attr, multitenant_attr)
ensure_single_column_index(dsl_state, source_attr, multitenant_attr, allow_nil?)
end

defp ensure_composite_index(
Expand All @@ -112,7 +113,8 @@ defmodule AshPostgresBelongsToIndex.Transformer do
source_attr,
multitenant_attr,
has_manual_ref,
manual_references
manual_references,
allow_nil?
) do
composite_fields = build_index_fields(source_attr, multitenant_attr)

Expand All @@ -124,28 +126,40 @@ defmodule AshPostgresBelongsToIndex.Transformer do
dsl_state
else
case has_manual_ref do
true -> add_custom_index(dsl_state, composite_fields, [])
false -> add_indexed_reference(dsl_state, name)
true ->
add_custom_index(dsl_state, composite_fields, partial_opts(source_attr, allow_nil?))

false ->
add_indexed_reference(dsl_state, name, allow_nil?)
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, nil, _allow_nil?), do: dsl_state

defp ensure_single_column_index(dsl_state, source_attr, tenant_attr) do
defp ensure_single_column_index(dsl_state, source_attr, tenant_attr, allow_nil?) 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,
include_base_filter?: false,
name: foreign_key_index_name(dsl_state, source_attr)
add_custom_index(
dsl_state,
[source_attr],
[
all_tenants?: true,
include_base_filter?: false,
name: foreign_key_index_name(dsl_state, source_attr)
] ++ partial_opts(source_attr, allow_nil?)
)
end
end

# Nullable FKs get partial indexes: FK lookups always match non-NULL values,
# so excluding NULL rows keeps the index smaller at no cost.
defp partial_opts(_source_attr, false), do: []
defp partial_opts(source_attr, true), do: [where: "#{source_attr} IS NOT NULL"]

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)
Expand All @@ -159,11 +173,13 @@ defmodule AshPostgresBelongsToIndex.Transformer do
false

tenant_attr ->
# If looking for tenant_attr and there's any indexed reference, it will start with tenant_attr
# If looking for tenant_attr and there's any indexed reference, it will start with
# tenant_attr. Partial reference indexes (index_where on the FK column) don't count:
# they exclude rows where that FK is NULL, so they can't serve tenant-only lookups.
field == tenant_attr &&
dsl_state
|> Transformer.get_entities([:postgres, :references])
|> Enum.any?(& &1.index?)
|> Enum.any?(&(&1.index? && is_nil(Map.get(&1, :index_where))))
end
end

Expand All @@ -183,14 +199,22 @@ defmodule AshPostgresBelongsToIndex.Transformer do
Transformer.add_entity(dsl_state, [:postgres, :custom_indexes], index, type: :append)
end

defp add_indexed_reference(dsl_state, relationship_name) do
defp add_indexed_reference(dsl_state, relationship_name, allow_nil?) do
reference_opts = [relationship: relationship_name, index?: true]

reference_opts =
if allow_nil? do
Keyword.put(reference_opts, :index_where, :not_nil)
else
reference_opts
end

{:ok, reference} =
Transformer.build_entity(
AshPostgres.DataLayer,
[:postgres, :references],
:reference,
relationship: relationship_name,
index?: true
reference_opts
)

Transformer.add_entity(dsl_state, [:postgres, :references], reference, type: :append)
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule AshPostgresBelongsToIndex.MixProject do
def project do
[
app: :ash_postgres_belongs_to_index,
version: "0.4.0",
version: "0.5.0",
elixir: "~> 1.17",
consolidate_protocols: Mix.env() not in [:dev, :test],
start_permanent: Mix.env() == :prod,
Expand Down
Loading
Loading