Search code examples
elixirphoenix-frameworkecto

How to design an Ecto changeset as a true before_save callback


I've a simple before_save transformation and learned that phoenix uses Ecto changest for this task.

My Stage model has a position attribute which defaults to the current maximum + 1 so tried to implement this as follows:

Stage Model:

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, @required_fields, @optional_fields)
    |> validate_required([:name])
    |> set_position
  end

  defp set_position(current_changeset) do
    # get current max position from db
    max_position = Repo.one(
      from s in Stage,
      select: fragment("COALESCE(MAX(?),0)", s.position)
    )

    case current_changeset do
      %Ecto.Changeset{valid?: true} -> 
        put_change(current_changeset, :position, max_position+1)
      _ ->
        current_changeset
    end
  end

Which works fine when inserting one by one records but fails in bulk insertion; for example in below seed file.

Seed

alias MyApp.{Repo, Post}

[
  %{name: "Requirements"},
  %{name: "Quotation"},
  %{name: "Development"},
  %{name: "Closing"}
] 
|> Enum.map(&Post.changeset(%Post{}, &1)) 
|> Enum.each(&Repo.insert!(&1))

Expected/Current Behavior:

If current max position is say 7, for all inserted 4 records above, position will be set to 8 instead of 8,9,10,11 respectively! That's because the first pipe will prepare all changests then insert them!

Is the way i am seeding wrong? or the changeset? How can i re-design this so the behavior is the same regardless of how i do insertions? any feedback to improve how i am doing it is appreciated!


Solution

  • You can use Ecto.Changeset.prepare_changes/2 to run arbitrary computation inside the database transaction of that changeset. Your set_position/1 function has the correct argument/return value (changeset -> changeset), so you just need to change:

    |> set_position
    

    to

    |> prepare_changes(&set_position/1)
    

    and set_position will now be executed in the same transaction as and just before your Post is inserted, instead of being executed while the changeset is created.