coen.bakker

coen.bakker

TLDR; Is this a good way to writing context functions? Especially when preloads are nested, or for some other reason there is some complexity. If not, what is the way to go?

I was reading the topic Preloading, some of the time, all of the time, none of the time? from some years ago. In the topic a number of different approaches to implementing context functions are mentioned. It also covers when to preload and why, as the title of the referenced topic suggests.

I rewrote a get_post/2 function of mine, because after have read the mentioned topic, among others, I felt it needed improvement.

How close is this to what could be considered good practice? Am I missing something still? For example, will this approach bite me later on, when requirements shift?

One thing I noticed myself is that my context module now has a lot more private functions in it than before. Those distract a bit from the top level functions that are actually the ones that I would call from the web layer. Do you put these private functions somewhere else? For example, under the post schema in Post.ex?

Quick background: A post has many post replies. And a post reply has many post subreplies. The post and post reply schema’s each have a virtual field for the (sub)reply count.

@doc """
  Returns the post with the given `id`.

  ## Options
  * `:preload_user` - preload the user association of the post, its replies, and its subreplies (:all), or a keyword list of fields to select from the user table
  * `:preload_replies` - a boolean indicating whether to preload the replies association (default: false)
  * `:preload_subreplies` - a boolean indicating whether to preload the subreplies association (default: false)
  * `:with_reply_count` - a boolean indicating whether to preload `:reply_count` and `:subreply_count` virtual fields (default: false)

  ## Example

      %Post{} = Posts.get_post(
        post_id,
        preload_user: [:id, :avatar, :username],
        preload_replies: true,
        preload_subreplies: true,
        with_reply_count: true
      )

  """

  def get_post(id, opts \\ []) do
    from(p in Post, where: p.id == ^id)
    |> add_reply_count(opts)
    |> preload_user(opts)
    |> preload_replies(opts)
    |> preload_subreplies(opts)
    |> Repo.one()
  end

  defp add_reply_count(query, opts) do
    case Keyword.get(opts, :with_reply_count, false) do
      true ->
        from post in query,
          left_join: reply in assoc(post, :replies),
          left_join: subreply in assoc(reply, :subreplies),
          group_by: [post.id],
          select_merge: %{reply_count: count(reply.id, :distinct) + count(subreply.id)}

      _ ->
        query
    end
  end

  defp preload_user(query, opts) do
    preload_user = Keyword.get(opts, :preload_user)

    case preload_user do
      :all ->
        from q in query,
          preload: [:user]

      nil ->
        query

      fields ->
        user_query =
          from u in User,
            select: ^fields

        from q in query,
          preload: [user: ^user_query]
    end
  end

  defp preload_replies(query, opts) do
    case Keyword.get(opts, :preload_replies) do
      true ->
        replies_query =
          from(PostReply)
          |> sort_by_inserted_at()
          |> add_subreply_count(opts)
          |> preload_user(opts)

        from post in query,
          preload: [replies: ^replies_query]

      _ ->
        query
    end
  end

  defp sort_by_inserted_at(query) do
    from q in query,
      order_by: [asc: q.inserted_at]
  end

  defp add_subreply_count(query, opts) do
    case Keyword.get(opts, :with_reply_count, false) do
      true ->
        from reply in query,
          left_join: subreply in assoc(reply, :subreplies),
          group_by: [reply.id],
          select_merge: %{subreply_count: count(subreply.id)}

      _ ->
        query
    end
  end

  defp preload_subreplies(query, opts) do
    case Keyword.get(opts, :preload_subreplies) do
      true ->
        subreplies_query =
          from(PostSubreply)
          |> sort_by_inserted_at()
          |> preload_user(opts)

        from subreply in query,
          preload: [replies: [subreplies: ^subreplies_query]]

      _ ->
        query
    end
  end

Showing Posts 1 to 10

LostKobrakai

LostKobrakai

I’m not sure there’s much consense of how exactly to write that portion of you codebase.

The best way to prepare for future requirements is make it easy to throw away the current code and replace it wholesale (https://www.youtube.com/watch?v=1FPsJ-if2RU). There’s no way to know what future requirements will be, so trying to cater to them is guesswork at best.

On a general note I’d always suggest to start with less abstraction (less complex parameters) and more distinct functions than the other way round. It leads to the above, but also means you discover useful abstractions rather than imagine them to be useful. Simpler more distinct functions should help against “just let this existing function do one more thing”.

Your example would lend itself to extacting common query manipulating functions into their own module. My suggestion for learning how to do layering in code (without necessarily buying into buzzword architecture) would be buying “grokking functional programming”. It has a few great chaptures on how to build larger stuff out of smaller pieces and how those layers should depend on each other (or not).

coen.bakker

coen.bakker OP

It definitely felt like I was writing an unnecessarily general get_post/2 function. In reality I currently only need to be able to do the following.

Posts.get_post(
        post_id,
        preload_user: [:id, :avatar, :username],
        preload_replies: true,
        preload_subreplies: true,
        with_reply_count: true
      )

I got tempted…

:roll_eyes:

coen.bakker

coen.bakker OP

Maybe silly, but thinking of a good name for the function that would be equivalent to

Posts.get_post(
        post_id,
        preload_user: [:id, :avatar, :username],
        preload_replies: true,
        preload_subreplies: true,
        with_reply_count: true
      )

threw me off.

:sweat_smile:

adw632

adw632

If it were me I would create my queries separately to my context functions and build out the semantic context functions using the queries and schema modules.

In my query module I would expose those preload functions and let the caller (the context module) decide what they need by chaining them together. I would allow specifying options to those query functions (like fields to return), and perhaps some sort and aggregate helpers also.

Context should be high level enough that for callers (eg LiveView or controller actions) it would not matter if your entire backend storage layer changed. Generally I think of context methods as orchestrating actions. If they involve multiple resources or a mix of ecto, external apis, sending email or pubsub notifications it shouldn’t matter.

coen.bakker

coen.bakker OP

Like so?

@doc """
Gets data of a post that is necessary for rendering  a dialog component.
  
## Options
  ...all (preload) options...
  ...preloading is not optional anymore, since the function is now specialized...

"""
  def get_post_for_dialog(id, opts \\ []) do
    from(p in Post, where: p.id == ^id)
    |> Posts.Query.add_reply_count()
    |> Posts.Query.preload_user(opts)
    |> Posts.Query.preload_replies(opts)
    |> Posts.Query.preload_subreplies(opts)
    |> Repo.one()
  end

Or without passing the opts to get_post_for_dialog? Like this. So reading the function becomes more semantic (explains more completely what to expect the function to do/return)?

  def get_post_for_dialog(id) do
    from(p in Post, where: p.id == ^id)
    |> Query.add_reply_count()
    |> Query.preload_user(fields: [:id, :avatar, :username])
    |> Query.preload_replies(with_user?: true, with_subreply_count?: true)
    |> Query.preload_subreplies(with_user?: true)
    |> Repo.one()
  end

Edit: I use a UI concept in the function name. Is that cursed?

egze

egze

We are doing something similar at work, but we generate all the functions and they follow the same naming convention.

The problem with

  def get_post(id, opts \\ []) do
    from(p in Post, where: p.id == ^id)
    |> add_reply_count(opts)
    |> preload_user(opts)
    |> preload_replies(opts)
    |> preload_subreplies(opts)
    |> Repo.one()
  end

is that you can’t reuse it for other contexts, as it is too specific for the Post schema.

We have something like this in all contexts:

use MyApp.Context,
    queries: MyApp.Queries.ActionQueries,
    schema: MyApp.Schemas.Action

And it generates def get_action(action_id, opts \\ []) and def list_actions(opts \\ []) and some other stuff based on the schema name.

mayel

mayel

That makes a lot of sense, though that’s easier to do when the code is quicker to write and easier to read, eg. using something like EctoSparkles — ecto_sparkles v0.3.0 (shameless plug) for joins/preloads which is less verbose than the standard ecto syntax.

adw632

adw632

Pretty much either of those is ok, and as @egze provided.

The key is to be declarative, the what and not the how or exposing the implementation. So specifying the fields or aggregates or order you want is fine.

But also think beyond just Ecto concerns in your contexts. You may also notify on certain events using Phoenix pub sub topics, send emails or orchestrate additional activities via Oban, call web hooks for integrations and so on.

Some other things to think about…

Think beyond CRUD concepts. If you had say a ticketing system you would have semantic actions like open ticket, close ticket.

Think about access control enforcement in your contexts.

Aim for thin controllers and live views and fat contexts where the guts of your application and logic lives.

sodapopcan

sodapopcan

I typically create functions that are tailored to their business purpose—or, ahem context—as opposed to generic functions that take all sorts of parameters. In your example you have way too many permutations that I imagine you aren’t going to be using even half of (maybe only 2 or 3?). Write getters that describe the purpose you are getting them for (and I’m now seeing @adw632 has just said the same thing as I’m writing this).

I also sometimes forgo preloads and just run separate queries from separate context functions. Contrary to popular belief, running a single query isn’t guaranteed to be more efficient than running multiple. Not only is this true of SQL its especially true in Ecto since a single query will return a single, denormalized table which must be reduced in Elixir. This is slooow for larger datasets. In any event, this is totally situational but can work well in some cases. I would say Posts and Replies is one of these situations depending on how you have your data set up. Like, if creating a new reply is done through Posts.update_post(params) where there are cast_assocs all the way down, then obviously you need to preload. But if you have more specific, business-named functions like Posts.reply(user, post, params) or Replies.reply_to_post(user, post, params) then you aren’t gaining much from preloading (if you’re doing the multiple-query kind) other than being able to do @post.replies instead of just @replies.

adw632

adw632

I’m glad you pointed this out. The thing to avoid is 1+N queries. Using 2 or 3 queries to resolve each level is a lot less data repetition.

Query strategy is important. If you’re returning join IDs then you have already lost and just multiplied the left table data by the number of rows on the right just to get their ID. I also dislike assembling and passing lots of IDs into the second or third level query and prefer to scope the second or third level query on the prior queries constraint. I remember dealing with certain ORMs back in the day that would use the ID strategy, it’s ok with small queries but less than ideal with larger ones.

Where Next? Top

Trending in Questions Top

Blokh
Hey guys, I’ve got a huge CSV ( around 10 GB ) that needs to be processed hourly Do you guys have any suggestions what is the best prac...
New
RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kszambelanczyk
Hello! Could someone please give me a help/sample code, how to delete a file from s3 using waffle/waffle_ecto from Phoenix app. I creat...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge & Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews