My function raises a CompileError for defaults conflicts when [I believe] it shouldn't

I have a library that injects functions in Ecto schemas through metaprogramming (GitHub - joeljuca/swiss_schema: A Swiss Army knife for your Ecto schemas). The functions are equivalent to Ecto’s Query and Schema APIs, so you can have a query toolkit without having to generate, and maintain, ctx modules just for basic database operations.

However, one of these functions is throwing a CompileError which I don’t fully understand. The following functions aggregate/2 and aggregate/3:

# The function bodies don't matter much to this issue

@impl SwissSchema
def aggregate(:count, opts \\ []) do
  # ...
end

@impl SwissSchema
def aggregate(type, field, opts \\ []) do
  # ...
end

Raises the following CompileError:

(CompileError) def aggregate/3 defaults conflicts with aggregate/2

This CompileError doesn’t make sense to me. How would these defaults conflict with each other? I’ve tried to add guard clauses to it, but I still had no success.

Help? :slight_smile:

PS: The real-world source code is available here if it helps you understand it better: https://github.com/joeljuca/swiss_schema/blob/v0.4.0/lib/swiss_schema.ex#L163-L170.

There’s also an aggregate/1 version of this function that should not exist and will be removed in the next release.

Elixir’s default arguments are not technically defaults. \\ is part of the def macro that writes a new function with the appropriate arity for the given default.

defmodule Foo do
  def bar(baz \\ "baz") do
    baz
  end
end

gets rewritten to:

defmodule Foo do
  def bar do
    "baz"
  end

  def bar(baz) do
    baz
  end
end
5 Likes

How would you distinguish calling aggregate/2 without relying on the default parameter vs wanting aggregate/3 hoping for the default?

There are answers to that question, but most of those answers depend on what I know as a human looking at the code which is more than the compiler can assume. That’s the compiler’s complaint: it can’t figure out the difference between aggregate/2 when both parameters are given and aggregate/3 when you’re relying on the default: both are looking for two parameters.

1 Like

To be more accurate:

defmodule Foo do
  def bar(baz \\ "baz") do
    baz
  end
end

gets rewritten to something like:

defmodule Foo do
  def bar do
    bar("baz")
  end

  def bar(baz) do
    baz
  end
end

:nerd_face:

6 Likes

Try sth like this:

@impl SwissSchema
def aggregate(:count) do
  aggregate (:count, [])
end

@impl SwissSchema
def aggregate(type, field_or_opts) when is_keyword(field_or_opts) do
  aggregate(type, field_or_opts, [])
end

def aggregate(type, field_or_opts) do
  # ...
end

@impl SwissSchema
def aggregate(type, field, opts) do
  # ...
end
1 Like

I ended up using similar.

Btw – here are the typespecs for these callbacks:

@callback aggregate(type :: :count) :: term() | nil

@callback aggregate(type :: :count, opts :: Keyword.t()) :: term() | nil

@callback aggregate(type :: :avg | :count | :max | :min | :sum, field :: atom(), opts :: Keyword.t()) :: term() | nil

And here’s what I came up with:

@impl SwissSchema
# 1
def aggregate(type, opts \\ [])

# 2
def aggregate(:count, opts) when is_list(opts) do
  unquote(repo).aggregate(__MODULE__, :count, opts)
end

# 3
def aggregate(type, field) when is_atom(field) do
  aggregate(type, field, [])
end

@impl SwissSchema
# 4
def aggregate(type, field, opts) do
  unquote(repo).aggregate(__MODULE__, type, field, opts)
end

I’ve placed numbered comments in each part of the solution, so I can comment them separately.

  1. aggregate(type, opts \\ []) sets up aggregate/1 and aggregate/2. This is needed to allow aggregate(:count) calls – a special case in Ecto.Repo’s aggregate/3 callback (PS: I don’t like it much, but I’m supporting it for compatibility and DX)
  2. aggregate(:count, opts) when is_list(opts) accounts for aggregate(:count, [ ... ]) calls
  3. aggregate(type, field) when is_atom(field) is what seems to be solving the issue. This is a special case for SwissSchema’s aggregate/2 callback: if it’s used with an atom as the second argument, it means the caller is actually trying to use aggregate/3 with its default empty-list opts argument, so I call it directly
  4. aggregate(type, field, opts) is SwissSchema’s aggregate/3 callback (no changes)

I would also be happy to hear what you think of this solution. :slight_smile:

I did not know that! Good to know. Thanks for the lesson and examples!