Questions regarding phoenix associations

Hey,

I’m still pretty new using phoenix, but coming from the rails world, things go well. :smile:

I’m wondering how/when models’ associations are loaded.

Eg. A typical case is:

# in the controller
def show(conn, %{"id" => id}) do
  object = Repo.get(Object, id) |> Repo.preload(:association)
  other_variable = object.association.name
  Object.do_something(object)
  # etc.
end

# in the model
def do_something(%__MODULE__{} = object) do
  object = object |> Repo.preload(:association)
  get_something = object.association.whatever
  # etc.
end

So, the situation is, I need to preload :association in the controller, but I will use it in the model too. For independence reasons, I want to be able to call Object.do_something(object) somewhere else than in the controller (eg. IEX), so I want to make sure :association is preloaded within the model’s method.

Questions:

  • Is this a good practice to start every method with the preload of all required associations at every level for layer independence?
  • Do you confirm that if it is already loaded, nothing happens, hence there is no penalty for trying?
  • Do you also confirm that models, like in ActiveRecord, are loaded lazily, which means it waits for object.attribute to being called to actually trigger the Repo.get(Object, id) query?
  • Since it is so common, shouldn’t there be a more elegant syntax than having to reassign object = object |> Repo.preload(:association)?
  • Going further, I’m wondering why shouldn’t there be a behavior of autoload: if object.association is NotYetLoaded, the query goes, and if it needs to be loaded earlier (eg. to avoid N+1 requests), then it is actually loaded earlier the proper way.

Thanks for clarification

Never, not automatically anyway.

No. That would be extra SQL queries in places where you might not need them.

Correct. Doing something like this:

order = Repo.get(Order, 1)
order = Repo.preload(order, :customer)
order = Repo.preload(order, :customer)

…will result in loading the customer only once.

No. You have no object to call before loading it in the first place. You get access to fields after you load their records from the DB.

Sure. Roll your own, takes a minute:

defmodule Orders do
  def with_customer(order_id) do
    Repo.get(Order, order_id)
    |> Repo.preload(:customer) 
  end
end

# ...much later in your controller or anywhere else...
order_to_show_in_ui = Orders.with_customer(params["order_id"])

Have a look at Phoenix contexts – one of their purposes is to wrap a category / group of functionality into a singular module so the programmers also get a good idea which is where, topically, in the project.

Phoenix is not designed to pollute your project’s codebase with a lot of auto-generated conveniences. When you need one such, it’s relatively easy to make Phoenix generate its scaffold for you – or you roll your own as above (I usually do that but it’s down to preference really).

It’s a philosophical thing. Authors of Phoenix don’t like implicit behaviour. Don’t forget that some of them come from Rails and don’t want to emulate it.


TL;DR: The idea is to be explicit and not implicit.

2 Likes

While it is an advantage to know Rails for Phoenix, it is a disadvantage to think in OOP with FP language like Elixir.

It does reflect in some of your comments :slight_smile:

  • There are no objects, nor models, but schemas
  • There are no methods, but functions
  • There are no callback in Ecto, but Ecto.Multi, and as mentionned in the previous post, Ecto favors explicit over implicit
1 Like

Good points. One thing to point out though, by default, Repo.preload/3 won’t reload a preloaded association. So,

record = Repo.preload(record, :association) # this will hit the DB if :association is not loaded
record = Repo.preload(record, :association)  # this will NOT hit the DB since association is already loaded

You can force-load an association by:

Repo.preload(record, :association, force: true)

2 Likes

Right, I forgot that one. Edited my post.

It seems you’re still thinking in the rails way of doing Model.find(id).
I would suggest not using Repo in your controllers. Maybe have a function you call instead of using Repo everywhere.

def get_user(%User{} = user), do: Repo.preload(user, :likes)
def get_user(id) do
  from(u in User, where: u.id == ^id, preload: [:likes])
  |> Repo.one()
end

This will get you a preloaded user every time you call the function whether with a user id or with a user record. Doing

32 |> get_user() |> get_user() |> get_user() # will hit the DB once
# or
current_user |> get_user() |> get_user() # might hit the DB, but also only once

I wouldn’t say this is the recommended way, but it should work for you. You might want to rethink the structure/flow of your code in the future.

One thing to keep in mind, in phoenix (or ecto particularly), you never hit the DB unless you are calling one of the Repo functions. No Repo call, no DB hit.

1 Like