Search code examples
elixirecto

Use build association between schemas


I have following schemas:

The first:

schema "countries_codes" do

    # Country code based on ISO-2
    field :iso,        :string
    field :name,       :string
    has_many :country, Country

    timestamps

  end

  def changeset(struct, params \\ %{}) do

    struct
    |> cast(params, [:iso, :name])
    |> validate_required([:iso, :name])
    |> validate_length(:iso, max: 2)

  end

the second:

schema "languages_codes" do

    # Language code based on ISO-2
    field :iso,        :string
    field :name,       :string
    has_many :country, Country

    timestamps

  end

  def changeset(struct, params \\ %{}) do

    struct
    |> cast(params, [:iso, :name])
    |> validate_required([:iso, :name])
    |> validate_length(:iso, max: 2)

  end

and the third:

schema "countries" do

    belongs_to :country_iso,  CountryCode
    belongs_to :language_iso, LanguageCode
    field :name,              :string

    timestamps

  end

  def changeset(struct, params \\ %{}) do

    struct
    |> cast(params, [:country_iso, :language_iso, :name])
    |> cast_assoc(:country_iso)
    |> validate_required([:country_iso, :language_iso, :name])

  end

as you can see on the third table, the first and second field belong to other schema.

When I run the country changeset function, I've got:

** (RuntimeError) casting assocs with cast/3 is not supported, use cast_assoc/3 instead
    (ecto) lib/ecto/changeset.ex:440: Ecto.Changeset.type!/2
    (ecto) lib/ecto/changeset.ex:415: Ecto.Changeset.process_param/8
    (elixir) lib/enum.ex:1247: Enum."-map_reduce/3-lists^mapfoldl/2-0-"/3
    (ecto) lib/ecto/changeset.ex:391: Ecto.Changeset.do_cast/7
    (busiket) web/models/country.ex:20: Busiket.Country.changeset/2
    (elixir) lib/enum.ex:1184: Enum."-map/2-lists^map/1-0-"/2
    priv/repo/seeds.exs:48: (file)
    (elixir) lib/code.ex:363: Code.require_file/2
    (mix) lib/mix/tasks/run.ex:71: Mix.Tasks.Run.run/1
    (mix) lib/mix/task.ex:296: Mix.Task.run_task/3
    (mix) lib/mix/cli.ex:58: Mix.CLI.run_task/2
    (elixir) lib/code.ex:363: Code.require_file/2

My problem is, I do not know, how to write a changeset function for the schema countries.


Solution

  • To fix error remove :country_iso and :language_iso form cast/3 in schema countries.

    Updated model Country:

    defmodule MyApp.Country do
      use Ecto.Schema
      import Ecto.Changeset
    
      schema "countries" do
        field :name, :string
        belongs_to :country_iso, MyApp.CountryCode
        belongs_to :language_iso, MyApp.LanguageCode
    
        timestamps()
      end
    
      def changeset(struct, params \\ %{}) do
        struct
        |> cast(params, [:name, :country_iso_id, :language_iso_id])
        |> cast_assoc(:country_iso)
        |> cast_assoc(:language_iso)
        |> unique_constraint(:country_iso_id)
        |> unique_constraint(:language_iso_id)
        |> validate_required([:name])
      end
    end
    

    To create a new Country with new CountryCode and LanguageCode:

    changeset = Country.changeset(%Country{}, %{
      name: "United Kingdom",
      country_iso: %{iso: "GB", name: "United Kingdom"},
      language_iso: %{iso: "en", name: "English"}
    })
    Repo.insert! changeset
    

    Cannot insert changeset with duplicated associations

    Repo.insert! %CountryCode{iso: "GB", name: "United Kingdom"}
    
    changeset = Country.changeset(%Country{}, %{
      name: "United Kingdom",
      country_iso: %{iso: "GB", name: "United Kingdom 2"},
      language_iso: %{iso: "en", name: "English"}
    })
    {:error, changeset} = Repo.insert changeset
    changeset.valid? # => false
    changeset.changes[:country_iso].errors[:iso] # => {"has already been taken", []}
    

    Need to add unique_constraint(:iso) into CountryCode changeset to make it work like that.

    Insert changeset with existent association

    country_iso = Repo.insert! %CountryCode{iso: "GB", name: "United Kingdom"}
    changeset = Country.changeset(%Country{}, %{
      name: "United Kingdom",
      country_iso_id: country_iso.id,
      language_iso: %{iso: "en", name: "English"}
    })
    Repo.insert! changeset
    country = Repo.one Country
    country.country_iso_id == country_iso.id # => true
    

    Consider using column iso as primary key for tables country_codes and language_codes

    See more - Ecto Custom Primary Keys

    Migrations:

    defmodule MyApp.Repo.Migrations.CreateCountryCode do
      use Ecto.Migration
    
      def change do
        create table(:countries_codes, primary_key: false) do
          add :iso, :string, size: 2, primary_key: true
          add :name, :string
    
          timestamps()
        end
      end
    end
    
    defmodule MyApp.Repo.Migrations.CreateLanguageCode do
      use Ecto.Migration
    
      def change do
        create table(:languages_codes, primary_key: false) do
          add :iso, :string, size: 2, primary_key: true
          add :name, :string
    
          timestamps()
        end
      end
    end
    
    defmodule MyApp.Repo.Migrations.CreateCountry do
      use Ecto.Migration
    
      def change do
        create table(:countries) do
          add :name, :string
          add :country_iso, references(:countries_codes, column: :iso, type: :string, on_delete: :nothing)
          add :language_iso, references(:languages_codes, column: :iso, type: :string, on_delete: :nothing)
    
          timestamps()
        end
    
        create unique_index(:countries, [:country_iso, :language_iso])
      end
    end
    

    Models:

    defmodule MyApp.CountryCode do
      use Ecto.Schema
      import Ecto.Changeset
    
      @primary_key {:iso, :string, []}
      schema "countries_codes" do
        field :name, :string
        has_many :countries, MyApp.Country
    
        timestamps()
      end
    
      def changeset(struct, params \\ %{}) do
        struct
        |> cast(params, [:iso, :name])
        |> validate_required([:iso, :name])
        |> validate_length(:iso, max: 2)
        |> unique_constraint(:iso)
      end
    end
    
    defmodule MyApp.LanguageCode do
      use Ecto.Schema
      import Ecto.Changeset
    
      @primary_key {:iso, :string, []}
      schema "languages_codes" do
        field :name, :string
        has_many :countries, MyApp.Country
    
        timestamps()
      end
    
      def changeset(struct, params \\ %{}) do
        struct
        |> cast(params, [:iso, :name])
        |> validate_required([:iso, :name])
        |> validate_length(:iso, max: 2)
        |> unique_constraint(:iso)
      end
    end
    
    defmodule MyApp.Country do
      use Ecto.Schema
      import Ecto.Changeset
    
      schema "countries" do
        field :name, :string
        belongs_to :country_code, MyApp.CountryCode, foreign_key: :country_iso, references: :iso, type: :string
        belongs_to :language_code, MyApp.LanguageCode, foreign_key: :language_iso, references: :iso, type: :string
    
        timestamps()
      end
    
      def changeset(struct, params \\ %{}) do
        struct
        |> cast(params, [:name, :country_iso, :language_iso])
        |> validate_required([:name, :country_iso, :language_iso])
        |> foreign_key_constraint(:country_iso)
        |> foreign_key_constraint(:language_iso)
      end
    end
    

    Note foreign_key_constraint/3 function - it adds validation error for Country changeset when nonexistent country_iso or language_iso is used.

    I've also changed associations names to country_code and language_code

    Assume there are some country code and language code:

    Repo.insert! %CountryCode{iso: "GB", name: "United Kingdom"}
    Repo.insert! %LanguageCode{iso: "en", name: "English"}
    

    To create Country changeset and insert into database:

    changeset = Country.changeset(%Country{}, %{name: "United Kingdom", country_iso: "GB", language_iso: "en"})
    Repo.insert! changeset