Stop rewriting Ecto queries from scratch - use `Ecto.Query.exclude/2` instead

almirsarajcic

almirsarajcic

7 hours ago

Building complex queries often means starting with a base query, then customizing it for different use cases. Most developers duplicate the entire query logic instead of using Ecto’s built-in composition tools.

# ❌ DUPLICATED LOGIC - Rewriting the entire query
def recent_posts_by_category(category) do
  from(p in Post,
    join: u in User, on: p.user_id == u.id,  # Duplicated join
    where: p.published == true,              # Duplicated condition
    where: p.inserted_at > ago(30, "day"),   # Duplicated condition
    where: p.category == ^category,          # New filter
    order_by: [desc: p.inserted_at],         # Duplicated ordering
    select: {p.title, u.name}                # Duplicated select
  )
end

# ✅ COMPOSABLE - Build on existing queries with exclude/2
def recent_posts_by_category(category) do
  base_recent_posts()
  |> exclude(:where)            # Remove existing where clauses
  |> where([p], p.published == true)
  |> where([p], p.inserted_at > ago(30, "day"))
  |> where([p], p.category == ^category) # Add new filter
end
# ✅ COMPOSABLE - Build on existing queries with exclude/2
def base_recent_posts do
  from(p in Post,
    join: u in User, on: p.user_id == u.id,
    where: p.published == true,
    where: p.inserted_at > ago(30, "day"),
    order_by: [desc: p.inserted_at],
    select: {p.title, u.name}
  )
end

def recent_posts_by_category(category) do
  base_recent_posts()
  |> exclude(:where)                            # Remove existing where clauses
  |> where([p], p.published == true)            # Re-add base conditions
  |> where([p], p.inserted_at > ago(30, "day"))
  |> where([p], p.category == ^category)        # Add new filter
end

def recent_posts_different_order do
  base_recent_posts()
  |> exclude(:order_by)            # Remove existing ordering
  |> order_by([p], [asc: p.title]) # Apply different ordering
end

Why exclude/2 works

Ecto queries are composable data structures. exclude/2 removes specific query parts (:where, :order_by, :select, :join, etc.) so you can replace them without starting from scratch.

# You can exclude multiple parts at once
base_query
|> exclude([:where, :order_by])
|> where([p], p.status == :active)
|> order_by([p], [desc: p.priority])

# Common excludable parts:
# :distinct, :where, :order_by, :group_by, :having
# :limit, :offset, :select, :join

This prevents query logic duplication and makes your context functions more maintainable. Instead of copy-pasting complex joins and conditions, you compose from proven base queries.

Pro tip: Use exclude/2 with dynamic filters to build search interfaces that modify base queries instead of constructing them conditionally.