Enforcing business requirements in a belongs-to relation

I am fairly new to Elixir/Phoenix and I would like someone to help sense check/review a high level sketch for this scenario:

Suppose that in my application, a Document belongs to a Folder (both of them are Ecto schemas). In addition, we require that during the creation of a Document, the chosen Folder should meet some business requirements (e.g., the Folder must be owned by the current user, or the Folder is not older than X, etc). To create a Document, let’s define this function:

def create_document(user, attrs) do
  %Document{}
  |> Document.changeset(user, attrs)
  |> Repo.insert()
end

In this case, attrs is provided by the user (e.g., coming from a controller). Thus, a malicious user can specify an invalid folder_id in it and we should guard against this in Document.changeset/3. Here is my implementation

defmodule MyApp.Document do
  import Ecto.Changeset

  def changeset(document, user, attrs) do
    document
    |> cast(attrs, [:attr1, :attr2]) # Omit folder_id from the list
    |> put_validated_folder(user, attrs["folder_id"])
    |> validate_required([:attr1, :attr2, :folder_id])
  end

  defp put_validated_folder(changeset, user, folder_id) do
    # Let's pretend we have get_folder which returns
    # - {:ok, folder} if the specified folder_id is "valid" based on some business logic
    # - {:error, reason} otherwise
    case get_folder(user, folder_id) do
      {:ok, folder} ->
        put_assoc(changeset, :folder, folder)
      _ ->
        changeset
    end
  end
end

Is this a good approach to this problem? Are there other better/more idiomatic ways?

I would pull folder_id out of the params in the controller and pass it as a separate arg:

def create(conn, %{"document" => %{"folder_id" => folder_id} = params}) do
  user = ...
  case MyApp.Documents.create_document(user, folder_id, params) do
    ...
  end
end

# handle invalid params if you think it makes sense
def create(conn, _params) do
  # ...
end
def create_document(user, folder_id, params) do
  case get_folder(user, folder_id) do
    {:error, ...} -> # handle the not found/unauthorized case
    {:ok, folder} -> # go ahead and create the document in the folder
  end
end

Just a side note: Ecto is fine with both string and atom keys in attrs when casting (but not both at the same time). This:

put_validated_folder(user, attrs["folder_id"])

won’t work if the keys in the attrs are atoms. Pulling specific keys in the controller works around this limitation.

2 Likes