From 26bc519a2f3696a5790b6b23588e669a9ab41b10 Mon Sep 17 00:00:00 2001 From: Jechol Lee Date: Wed, 15 Jul 2026 15:33:30 +0900 Subject: [PATCH] Use partial indexes for nullable relationships --- CHANGELOG.md | 25 ++++ README.md | 10 +- lib/transformer.ex | 60 ++++++--- mix.exs | 2 +- test/partial_index_test.exs | 241 ++++++++++++++++++++++++++++++++++++ test/transformer_test.exs | 31 +++-- usage-rules.md | 8 +- 7 files changed, 340 insertions(+), 37 deletions(-) create mode 100644 test/partial_index_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ec911..cb67395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 41e8787..c337d51 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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: diff --git a/lib/transformer.ex b/lib/transformer.ex index 81cdbe5..d2cb8db 100644 --- a/lib/transformer.ex +++ b/lib/transformer.ex @@ -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 @@ -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( @@ -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) @@ -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) @@ -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 @@ -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) diff --git a/mix.exs b/mix.exs index 3f4cad8..2caa089 100644 --- a/mix.exs +++ b/mix.exs @@ -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, diff --git a/test/partial_index_test.exs b/test/partial_index_test.exs new file mode 100644 index 0000000..a1fee3b --- /dev/null +++ b/test/partial_index_test.exs @@ -0,0 +1,241 @@ +defmodule AshPostgresBelongsToIndex.PartialIndexTest do + use ExUnit.Case, async: true + + defmodule Repo do + use AshPostgres.Repo, + otp_app: :ash_postgres_belongs_to_index, + warn_on_missing_ash_functions?: false + + def min_pg_version, do: %Version{major: 16, minor: 0, patch: 0} + end + + defmodule Author do + use Ash.Resource, domain: nil, data_layer: AshPostgres.DataLayer + + attributes do + integer_primary_key :id + end + + postgres do + table "authors" + repo Repo + end + end + + defmodule Post do + use Ash.Resource, + domain: nil, + data_layer: AshPostgres.DataLayer, + extensions: [AshPostgresBelongsToIndex] + + attributes do + uuid_primary_key :id + end + + relationships do + belongs_to :optional_author, Author + belongs_to :required_author, Author, allow_nil?: false + end + + postgres do + table "posts" + repo Repo + end + end + + defmodule Domain do + use Ash.Domain + + resources do + resource Author + resource Post + end + end + + defmodule IndexTarget do + use Ash.Resource, domain: nil, data_layer: AshPostgres.DataLayer + + attributes do + integer_primary_key :id + end + + postgres do + table "index_targets" + repo Repo + end + end + + defmodule FullIndexSource do + use Ash.Resource, domain: nil, data_layer: AshPostgres.DataLayer + + attributes do + integer_primary_key :id + end + + relationships do + belongs_to :target, IndexTarget + end + + postgres do + table "index_sources" + repo Repo + + references do + reference :target, index?: true + end + end + end + + defmodule PartialIndexSource do + use Ash.Resource, domain: nil, data_layer: AshPostgres.DataLayer + + attributes do + integer_primary_key :id + end + + relationships do + belongs_to :target, IndexTarget + end + + postgres do + table "index_sources" + repo Repo + + references do + reference :target, index?: true, index_where: "target_id IS NOT NULL" + end + end + end + + defmodule FullIndexDomain do + use Ash.Domain + + resources do + resource IndexTarget + resource FullIndexSource + end + end + + defmodule PartialIndexDomain do + use Ash.Domain + + resources do + resource IndexTarget + resource PartialIndexSource + end + end + + test "nullable relationships use partial indexes and required relationships use full indexes" do + references = AshPostgres.DataLayer.Info.references(Post) + + optional_author = Enum.find(references, &(&1.relationship == :optional_author)) + required_author = Enum.find(references, &(&1.relationship == :required_author)) + + assert optional_author.index? + assert optional_author.index_where == :not_nil + assert required_author.index? + assert is_nil(required_author.index_where) + end + + @tag :tmp_dir + test "generated migrations use a partial index only for nullable relationships", %{ + tmp_dir: tmp_dir + } do + migration_path = Path.join(tmp_dir, "migrations") + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: Path.join(tmp_dir, "snapshots"), + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + migration = + migration_path + |> Path.join("**/*_migrate_resources*.exs") + |> Path.wildcard() + |> Enum.map_join(&File.read!/1) + + assert migration =~ + ~S{create index(:posts, [:optional_author_id], where: "optional_author_id IS NOT NULL")} + + assert migration =~ ~S{create index(:posts, [:required_author_id])} + end + + @tag :tmp_dir + test "changing index_where replaces only the reference index", %{tmp_dir: tmp_dir} do + migration_path = Path.join(tmp_dir, "migrations") + snapshot_path = Path.join(tmp_dir, "snapshots") + + AshPostgres.MigrationGenerator.generate(FullIndexDomain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + AshPostgres.MigrationGenerator.generate(PartialIndexDomain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + migration = + migration_path + |> Path.join("**/*_migrate_resources*.exs") + |> Path.wildcard() + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + |> List.last() + |> File.read!() + + assert migration =~ ~S{drop_if_exists index(:index_sources, [:target_id])} + + assert migration =~ + ~S{create index(:index_sources, [:target_id], where: "target_id IS NOT NULL")} + + refute migration =~ ~S{drop constraint(:index_sources, "index_sources_target_id_fkey")} + refute migration =~ ~S{modify :target_id, references(} + refute migration =~ ~S{modify :target_id,} + end + + test "manual references without index? get partial custom indexes only for nullable FKs" do + defmodule ManualRefPost do + use Ash.Resource, + domain: nil, + data_layer: AshPostgres.DataLayer, + extensions: [AshPostgresBelongsToIndex] + + attributes do + uuid_primary_key :id + end + + relationships do + belongs_to :optional_author, Author + belongs_to :required_author, Author, allow_nil?: false + end + + postgres do + table "manual_ref_posts" + repo Repo + + references do + reference :optional_author, on_delete: :delete + reference :required_author, on_delete: :delete + end + end + end + + custom_indexes = AshPostgres.DataLayer.Info.custom_indexes(ManualRefPost) + + optional_index = Enum.find(custom_indexes, &(&1.fields == [:optional_author_id])) + required_index = Enum.find(custom_indexes, &(&1.fields == [:required_author_id])) + + assert optional_index.where == "optional_author_id IS NOT NULL" + assert is_nil(required_index.where) + end +end diff --git a/test/transformer_test.exs b/test/transformer_test.exs index 463fd15..25391fe 100644 --- a/test/transformer_test.exs +++ b/test/transformer_test.exs @@ -155,15 +155,16 @@ defmodule AshPostgresBelongsToIndex.TransformerTest do references = DslTransformer.get_entities(transformed_state, [:postgres, :references]) - # Company should NOT get a separate single-column index because the indexed - # references for user and depot already create indexes starting with company_id - # (e.g., [:company_id, :user_id]) which can satisfy FK lookups via leftmost prefix. + # Company DOES get a single-column index: the indexed references for user and + # depot are partial (index_where: :not_nil on their own FK), so they cannot + # serve company_id-only lookups for rows where user_id/depot_id is NULL. company_index = Enum.find(custom_indexes, fn index -> index.fields == [:company_id] end) - assert company_index == nil + assert company_index != nil + assert company_index.all_tenants? == true # Should NOT create redundant composite index [:company_id, :company_id] redundant_company_index = @@ -438,21 +439,26 @@ defmodule AshPostgresBelongsToIndex.TransformerTest do company_ref = Enum.find(references, &(&1.relationship == :company)) assert company_ref == nil - # Should create single-column custom indexes for user and depot only. - # Company does NOT need a separate index because the user/depot indexed references - # already create indexes starting with company_id (leftmost prefix rule). - assert length(custom_indexes) == 2 + # Should create single-column custom indexes for user, depot AND company. + # The user/depot indexed references are partial (index_where: :not_nil), so + # they cannot serve company_id-only lookups — company needs its own index. + assert length(custom_indexes) == 3 company_single_index = Enum.find(custom_indexes, &(&1.fields == [:company_id])) user_single_index = Enum.find(custom_indexes, &(&1.fields == [:user_id])) depot_single_index = Enum.find(custom_indexes, &(&1.fields == [:depot_id])) - assert company_single_index == nil + assert company_single_index != nil assert user_single_index != nil assert depot_single_index != nil + assert company_single_index.all_tenants? == true assert user_single_index.all_tenants? == true assert depot_single_index.all_tenants? == true + + # Nullable FKs get partial single-column indexes + assert user_single_index.where == "user_id IS NOT NULL" + assert depot_single_index.where == "depot_id IS NOT NULL" end test "creates custom indexes for multitenant resource with manual references" do @@ -485,14 +491,15 @@ defmodule AshPostgresBelongsToIndex.TransformerTest do assert user_single_index.all_tenants? == true assert depot_single_index.all_tenants? == true - # Company should NOT get a separate single-column index because other - # relationships create indexes starting with company_id (leftmost prefix rule) + # Company DOES get a separate single-column index: the other relationships' + # indexes are partial on their own FK, so they can't cover company_id-only lookups company_single_index = Enum.find(custom_indexes, fn index -> index.fields == [:company_id] end) - assert company_single_index == nil + assert company_single_index != nil + assert company_single_index.all_tenants? == true # Should NOT create redundant composite index [:company_id, :company_id] redundant_company_index = diff --git a/usage-rules.md b/usage-rules.md index 99f60f7..8163d79 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -24,12 +24,15 @@ This automatically generates: ```elixir postgres do references do - reference :user, index?: true - reference :category, index?: true + reference :user, index?: true, index_where: :not_nil + reference :category, index?: true, index_where: :not_nil end end ``` +Nullable relationships use partial indexes that exclude `NULL` values. Relationships configured +with `allow_nil? false` use full indexes. + ## Excluding Relationships Use the `except` option to exclude specific relationships: @@ -79,4 +82,3 @@ Generate migrations after adding the extension: ```bash mix ash.codegen add_belongs_to_indexes ``` -