How to write this in less possible code?

I am using something like this to filter Ecto.Query results:

  def build_query(query, "order_by", order_by, _conn) when order_by != "" do
    case order_by do
      "desc_inserted_at" -> Ecto.Query.order_by(query, [p], [desc: p.inserted_at])
      "asc_inserted_at" -> Ecto.Query.order_by(query, [p], [asc: p.inserted_at])
      "desc_title" -> Ecto.Query.order_by(query, [p], [desc: p.title])
      "asc_title" -> Ecto.Query.order_by(query, [p], [asc: p.title])
      _ -> query
    end
  end

I will be passing to this method order_by variables with values like name, id, inserted_at, etc preceded by either desc or asc, e.g. desc_inserted_at.

My question, how to make it in less repetitive code, especially when it comes to specifying the direction desc or asc ? Thank you.

1 Like

It would be easier to have a dir and order…

eg.

[h, t] = order_by |> String.split("_")
dir = h |> String.to_atom
order = t |> Enum.join("_") |> String.to_atom
Ecto.Query.order_by(query, [p], Keyword.put([], dir, Keyword.get(p, order)))

That does not check input validity, but You might see what I mean. It is not tested…

BTW You can use Regex like this to get metadata

iex> Regex.named_captures ~r/(?<dir>asc|desc)_(?<order>.*)/, "desc_inserted_at"
%{"dir" => "desc", "order" => "inserted_at"}

UPDATE: I have should have written [h | t], thanks @blatyo for spotting typo

4 Likes

I tend to use pattern matching in function heads. Here’s how I might approach it.

@order_by_fields ["inserted_at", "title"]
def build_query(query, "order_by", "", _conn), do: query
def build_query(query, "order_by", "desc_" <> field, _conn) when field in @order_by_fields do
  Ecto.Query.order_by(query, [p], [desc: String.to_atom(field)])
end
def build_query(query, "order_by", "asc_" <> field, _conn) when field in @order_by_fields do
  Ecto.Query.order_by(query, [p], [asc: String.to_atom(field)])
end
7 Likes

Looks like you meant [h | t] = order_by |> String.split("_")

3 Likes

Oh Yes, my bad :slight_smile:

2 Likes

Just add a step to the transformation that handles the repetitive bits. Easier to read than trying to meta program your way in to it.

  def build_query(query, "order_by", order_by, _conn) when order_by != "" do
    order_by
    |> case do
         "desc_inserted_at" -> [desc: p.inserted_at]
         "asc_inserted_at" -> [asc: p.inserted_at]
         "desc_title" -> [desc: p.title]
         "asc_title" -> [asc: p.title]
         _ ->[]
       end
    |> build_query_order
  end

  defp build_query_order([]), do: query
  defp build_query_order(params), Ecto.Query.order_by(query, [p], params)
1 Like
  def build_query(query, "order_by", order_by, _conn) when order_by != "" do
    order_by = case order_by do
      "desc_" <> field -> [desc: String.to_existing_atom(field)]
      "asc_" <> field -> [asc: String.to_existing_atom(field)]
      _other -> [] # will be ignored by the ecto query builder (the final sql statement won't have an ORDER BY)
    end
    Ecto.Query.order_by(query, order_by)
  end

I wouldn’t pass strings to a “context”, though. I would parse these strings at the boundary and turn them into more manageable data structures like {:order_by, :desc, :inserted_at}, which would be easier to handle inside the “context”.

On the boundary (maybe a controller):

valid_order_bys = [
  {"desc_inserted_at", {:desc, :inserted_at}}, # these can be automatically generated as well
  # etc ...
]

Enum.map(valid_order_bys, fn {valid_order_by_string, valid_order_by_tuple} ->
  defp parse_order_by(unquote(valid_order_by_string)), do: unquote(valid_order_by_tuple)
end)
defp parse_order_by(invalid_order_by_string) do
  # raise or log an error
end

# other parse_search_options clauses
defp parse_search_options([{"order_by", order_by} | rest], acc) do # I suspect it's for a search, but you can name it whatever you want
  parse_search_options(rest, [{:order_by, parse_order_by(order_by)} | acc])
end
# other parse_search_options clauses

Then in the “context”

# other build_query clauses
defp build_query([{:order_by, {direction, field} = opts} | rest], acc_query) do
  build_query(rest, Ecto.Query.order_by(acc_query, [opts]))
end
# other build_query clauses
1 Like

Thank you all for all possible solutions provided, I simply love Elixir. Much can be done with little code. I suspect other languages like JAVA won’t allow similar shortcuts, power and flexibility.

I may be wrong here but is it not slightly dangerous to use String.to_atom on what I suspect is user passed strings. This opens up an attack vector for memory issues because atoms are not garbage collected. Again correct me if I am wrong.

5 Likes

Yeah, it is dangerous. String.to_existing_atom/1 can be used instead.

2 Likes

Yes… You need to validate input in my code to avoid bad surprise :slight_smile:

I like @blatyo pattern matching solution, as it includes sanity check, but the main point is to separate order_by in direction/order, so that You need only one Ecto.Query command.

3 Likes

The reason I didn’t use String.to_existing_atom/1 is that it requires some code to have executed to create that atom. There are some situations where the atom may not exist yet, but will eventually. I check the resulting strings with field in @order_by_fields, so that I know those functions could only ever create two new atoms from user input.

EDIT:

iex(1)> String.to_existing_atom("foo")
** (ArgumentError) argument error
    :erlang.binary_to_existing_atom("foo", :utf8)
iex(1)> :foo
:foo
iex(2)> String.to_existing_atom("foo")
:foo
2 Likes

Oh yeah I know. My reply was in regards to the post marked as the solution.

2 Likes

String.to_existing_atom/1 should work with the atoms used in the ecto schema since they were already created for ecto structs.

2 Likes

I add an assignment like below in situations where I don’t know that the atoms will be present in the VM.

_ = [:prod, :staging, :dev, :test, :fooey]

String.to_existing_atom(System.get_env("MIX_ENV"))

Right, but if you’re calling String.to_existing_atom/1 on user input you still want to check if it’s a valid value, otherwise you’ll get an exception instead of being able to fall back to a valid behavior.

if you have column and sort direction as separate parameters you could do:

from query, order_by: ^([{:"#{direction}", :"#{column}"}])

2 Likes

Thanks for sharing, it is working, like this:

  def build_query(query, "order_by", order_by, _conn) when order_by != "" do
    [h | t] = order_by |> String.split("|")
    from query, order_by: ^([{:"#{h}", :"#{t}"}])
  end

But is this safe with regard to SQL injection or other vulnerabilities?

:"#{direction}" has the same problem as String.to_atom/1.

2 Likes

Of course guards or pattern matching should be used in that case, you could check parameter to be in schema.__schema__(:fields) ++ [:asc, :desc]

1 Like