Search documentation of ash

# Define Polymorphic Relationships

View Source

    Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)

    :ok

## Introduction

Something that comes up in more complex domains is the idea of "polymorphic relationships". For this example, we will use the concept of a `BankAccount`, which can be either a `SavingsAccount` or a `CheckingAccount`. All accounts have an `account_number` and `transactions`, but checkings & savings accounts might have their own specific information. For example, a `SavingsAccount` has an `interest_rate`, and a `CheckingAccount` has many `debit_card`s.

Ash does not support polymorphic relationships defined as relationships, but you can accomplish similar functionality via calculations with the type `Ash.Type.Union`.

For this tutorial, we will have a dedicated resource called `BankAccount`. I suggest taking that approach, as many things down the road will be simplified. With that said, you don't necessarily need to do that when there is no commonalities between the types. Instead of setting up the polymorphism on the `BankAccount` resource, you would define relationships to `SavingsAccount` and `CheckingAccount` directly.

This tutorial is not attempting to illustrate good design of accounting systems. We make many concessions for the sake of the simplicity of our example.

## Defining our Resources

    defmodule BankAccount do
      use Ash.Resource,
        domain: Finance,
        data_layer: Ash.DataLayer.Ets

      actions do
        defaults [:read, :destroy, create: [:account_number, :type]]
      end

      attributes do
        uuid_primary_key :id

        attribute :account_number, :integer, allow_nil?: false
        attribute :type, :atom, constraints: [one_of: [:checking, :savings]]
      end

      # calculations do
      #   calculate :implementation, AccountImplementation, GetAccountImplementation do
      #    allow_nil? false
      #  end
      # end

      relationships do
        has_one :checking_account, CheckingAccount
        has_one :savings_account, SavingsAccount
      end
    end

    defmodule CheckingAccount do
      use Ash.Resource,
        domain: Finance,
        data_layer: Ash.DataLayer.Ets

      actions do
        defaults [:read, :destroy, create: [:bank_account_id]]
      end

      attributes do
        uuid_primary_key :id
      end

      identities do
        identity :unique_bank_account, [:bank_account_id], pre_check?: true
      end

      relationships do
        belongs_to :bank_account, BankAccount do
          allow_nil? false
        end
      end
    end

    defmodule SavingsAccount do
      use Ash.Resource,
        domain: Finance,
        data_layer: Ash.DataLayer.Ets

      actions do
        defaults [:read, :destroy, create: [:bank_account_id]]
      end

      attributes do
        uuid_primary_key :id
      end

      identities do
        identity :unique_bank_account, [:bank_account_id], pre_check?: true
      end

      relationships do
        belongs_to :bank_account, BankAccount do
          allow_nil? false
        end
      end
    end

    defmodule Finance do
      use Ash.Domain,
        validate_config_inclusion?: false

      resources do
        resource BankAccount
        resource SavingsAccount
        resource CheckingAccount
      end
    end

    {:module, Finance, <<70, 79, 82, 49, 0, 0, 44, ...>>,
     [
       Ash.Domain.Dsl.Resources.Resource,
       Ash.Domain.Dsl.Resources.Options,
       %{opts: [], entities: [%Ash.Domain.Dsl.ResourceReference{...}, ...]}
     ]}

We haven't implemented the polymorphic part yet, but lets create a few of the above resources to show how they relate. Below we create a `BankAccount` for checkings, and a `BankAccount` for savings, and connect them to their "specific" types, i.e `CheckingAccount` and `SavingsAccount`.

We load the data, you can see that one `BankAccount` has a `:checking_account` but no `:savings_account`. For the other, the opposite is the case.

    bank_account1 = Ash.create!(BankAccount, %{account_number: 1, type: :checking})
    bank_account2 = Ash.create!(BankAccount, %{account_number: 2, type: :savings})
    checking_account = Ash.create!(CheckingAccount, %{bank_account_id: bank_account1.id})
    savings_account = Ash.create!(SavingsAccount, %{bank_account_id: bank_account2.id})

    [bank_account1, bank_account2] |> Ash.load!([:checking_account, :savings_account])

    [
      #BankAccount<
        implementation: #Ash.NotLoaded<:calculation, field: :implementation>,
        savings_account: nil,
        checking_account: #CheckingAccount<
          bank_account: #Ash.NotLoaded<:relationship, field: :bank_account>,
          __meta__: #Ecto.Schema.Metadata<:loaded>,
          id: "60f585f6-f352-410e-8c09-09ae49448851",
          bank_account_id: "21d27a6c-49b8-4984-a1b7-f2ef030626af",
          aggregates: %{},
          calculations: %{},
          ...
        >,
        __meta__: #Ecto.Schema.Metadata<:loaded>,
        id: "21d27a6c-49b8-4984-a1b7-f2ef030626af",
        account_number: 1,
        type: :checking,
        aggregates: %{},
        calculations: %{},
        ...
      >,
      #BankAccount<
        implementation: #Ash.NotLoaded<:calculation, field: :implementation>,
        savings_account: #SavingsAccount<
          bank_account: #Ash.NotLoaded<:relationship, field: :bank_account>,
          __meta__: #Ecto.Schema.Metadata<:loaded>,
          id: "d2bcc1cc-d709-418d-a9ff-0d21fd7d667b",
          bank_account_id: "4d8dc988-3e09-4efb-a643-d822013abfba",
          aggregates: %{},
          calculations: %{},
          ...
        >,
        checking_account: nil,
        __meta__: #Ecto.Schema.Metadata<:loaded>,
        id: "4d8dc988-3e09-4efb-a643-d822013abfba",
        account_number: 2,
        type: :savings,
        aggregates: %{},
        calculations: %{},
        ...
      >
    ]

## Defining our union type

Below we define an `Ash.Type.NewType`. This allows defining a new type that is the combination of an existing type and custom constraints.

    defmodule AccountImplementation do
      use Ash.Type.NewType, subtype_of: :union, constraints: [
        types: [
          checking: [
            type: :struct,
            constraints: [instance_of: CheckingAccount]
          ],
          savings: [
            type: :struct,
            constraints: [instance_of: SavingsAccount]
          ]
        ]
      ]
    end

    {:module, AccountImplementation, <<70, 79, 82, 49, 0, 0, 44, ...>>, :ok}

## Defining the calculation

Next, we'll define a calculation resolves to the specific type of any given account.

    defmodule GetAccountImplementation do
      use Ash.Resource.Calculation

      def load(_, _, _) do
        [:checking_account, :savings_account]
      end

      # This ensures that all attributes are selected by default
      # on the related loads we depend on.
      def strict_loads?, do: false

      def calculate(records, _, _) do
        Enum.map(records, &(&1.checking_account || &1.savings_account))
      end
    end

    {:module, GetAccountImplementation, <<70, 79, 82, 49, 0, 0, 13, ...>>, {:calculate, 3}}

## Adding the calculation to our resource

Finally, we'll add the calculation to our `BankAccount` resource!

For those following along with the LiveBook, go back up and uncomment the commented out calculation.

Now we can load `:implementation` and see that, for one account, it resolves to a `CheckingAccount` and for the other it resolves to a `SavingsAccount`.

    bank_account1 = Ash.create!(BankAccount, %{account_number: 1, type: :checking})
    bank_account2 = Ash.create!(BankAccount, %{account_number: 2, type: :savings})
    checking_account = Ash.create!(CheckingAccount, %{bank_account_id: bank_account1.id})
    savings_account = Ash.create!(SavingsAccount, %{bank_account_id: bank_account2.id})

    [bank_account1, bank_account2] |> Ash.load!([:implementation])

    [
      #BankAccount<
        implementation: #CheckingAccount<
          bank_account: #Ash.NotLoaded<:relationship, field: :bank_account>,
          __meta__: #Ecto.Schema.Metadata<:loaded>,
          id: "1d1a7b6c-dd08-4b8c-9769-2c1155a41a40",
          bank_account_id: "ce370381-303e-49dc-9950-a05b5052e7f8",
          aggregates: %{},
          calculations: %{},
          ...
        >,
        savings_account: #Ash.NotLoaded<:relationship, field: :savings_account>,
        checking_account: #Ash.NotLoaded<:relationship, field: :checking_account>,
        __meta__: #Ecto.Schema.Metadata<:loaded>,
        id: "ce370381-303e-49dc-9950-a05b5052e7f8",
        account_number: 1,
        type: :checking,
        aggregates: %{},
        calculations: %{},
        ...
      >,
      #BankAccount<
        implementation: #SavingsAccount<
          bank_account: #Ash.NotLoaded<:relationship, field: :bank_account>,
          __meta__: #Ecto.Schema.Metadata<:loaded>,
          id: "e537116d-c5b8-414a-a350-fa9b2afe0a4e",
          bank_account_id: "c4739158-2a1f-4de8-bccb-ee1d1ee9e602",
          aggregates: %{},
          calculations: %{},
          ...
        >,
        savings_account: #Ash.NotLoaded<:relationship, field: :savings_account>,
        checking_account: #Ash.NotLoaded<:relationship, field: :checking_account>,
        __meta__: #Ecto.Schema.Metadata<:loaded>,
        id: "c4739158-2a1f-4de8-bccb-ee1d1ee9e602",
        account_number: 2,
        type: :savings,
        aggregates: %{},
        calculations: %{},
        ...
      >
    ]

## Taking it further

One of the best things about using `Ash.Type.Union` is how it is integrated. Every extension (provided by the Ash team) supports working with unions. For example:

- Working with Unions in AshPhoenix.Form
- AshGraphql & AshJsonApi Unions

You can also synthesize filterable fields with calculations. For example, if you wanted to allow filtering `BankAccount` by `:interest_rate`, and that field only existed on `SavingsAccount`, you might have a calculation like this on `BankAccount`:

    calculate :interest_rate, :decimal, expr(
      if type == :savings do
        savings_account.interest_rate
      else
        0
      end
    )

This would allow usage like the following:

    BankAccount
    |> Ash.Query.filter(interest_rate > 0.01)
    |> Ash.read!()

← Previous Page Write Queries
Next Page → Test Resources