defprotocol Ecto.DevLogger.PrintableParameter do @moduledoc """ A protocol for rendering values as valid, copy‑pastable SQL expressions. `Ecto.DevLogger` calls this protocol for every bound parameter when it inlines values into the logged SQL. When should I implement this? - When a value you pass as a query parameter is a struct or type that does not have a built‑in implementation. - This is common with driver‑level custom/extension types (for example, structs from database driver libraries) or domain‑specific structs that ultimately become such driver types. `Ecto.DevLogger` operates below `Ecto.Type` casting and only sees the post‑cast values. Without an implementation, the logger falls back to `inspect/1`, which may not be valid SQL and therefore not directly runnable. How to implement - If your value can be represented as a single string literal that your database accepts, implement `to_string_literal/1` and have `to_expression/1` wrap it in single quotes. - If it must be rendered as a structured SQL expression (e.g. `ROW(...)`, casts, or constructor functions), implement `to_expression/1` directly and return `nil` from `to_string_literal/1`. Example: PostgreSQL composite type represented by a struct defmodule MyApp.Money do defstruct [:currency, :amount] end defimpl Ecto.DevLogger.PrintableParameter, for: MyApp.Money do def to_expression(%MyApp.Money{} = money) do string = to_string_literal(money) "'\#{String.replace(string, "'", "''")}'" end def to_string_literal(%MyApp.Money{currency: cur, amount: amt}) do "(\#{cur},\#{amt})" end end Arrays and tuples - When all elements implement `to_string_literal/1`, lists and tuples are formatted as PostgreSQL array and composite string literals (`'{...}'` and `'(...)'`). Otherwise, they are rendered using `ARRAY[...]` and `ROW(...)`, with each element formatted via `to_expression/1`. `to_expression/1` is the main function and `to_string_literal/1` is an optional helper for it. """ @doc """ Converts term to a valid SQL expression. """ @spec to_expression(any()) :: String.t() def to_expression(term) @doc """ Converts term to a string literal. """ @spec to_string_literal(any()) :: String.t() | nil def to_string_literal(term) end defmodule Ecto.DevLogger.Utils do @moduledoc false def in_string_quotes(string) do "'#{String.replace(string, "'", "''")}'" end def all_to_string_literal(list) do result = Enum.reduce_while(list, {:ok, []}, fn element, {:ok, acc} -> case Ecto.DevLogger.PrintableParameter.to_string_literal(element) do nil -> {:halt, :error} string_literal -> {:cont, {:ok, [{element, string_literal} | acc]}} end end) with {:ok, list} <- result do {:ok, Enum.reverse(list)} end end end defimpl Ecto.DevLogger.PrintableParameter, for: Atom do def to_expression(nil), do: "NULL" def to_expression(true), do: "true" def to_expression(false), do: "false" def to_expression(atom), do: atom |> Atom.to_string() |> Ecto.DevLogger.Utils.in_string_quotes() def to_string_literal(nil), do: "NULL" def to_string_literal(true), do: "true" def to_string_literal(false), do: "false" def to_string_literal(atom), do: Atom.to_string(atom) end defimpl Ecto.DevLogger.PrintableParameter, for: Map do def to_expression(map) do map |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(map), do: Jason.encode!(map) end for geo_mod <- [ Geo.Point, Geo.PointZ, Geo.PointM, Geo.PointZM, Geo.LineString, Geo.LineStringZ, Geo.LineStringZM, Geo.Polygon, Geo.PolygonZ, Geo.MultiPoint, Geo.MultiPointZ, Geo.MultiLineString, Geo.MultiLineStringZ, Geo.MultiPolygon, Geo.MultiPolygonZ, Geo.GeometryCollection ] do if Code.ensure_loaded?(geo_mod) do defimpl Ecto.DevLogger.PrintableParameter, for: geo_mod do def to_expression(geometry) do geometry |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(geometry), do: to_string(geometry) end end end defimpl Ecto.DevLogger.PrintableParameter, for: Tuple do def to_expression(tuple) do case to_string_literal(tuple) do nil -> "ROW(" <> Enum.map_join( Tuple.to_list(tuple), ",", &Ecto.DevLogger.PrintableParameter.to_expression/1 ) <> ")" value -> Ecto.DevLogger.Utils.in_string_quotes(value) end end def to_string_literal(tuple) do case Ecto.DevLogger.Utils.all_to_string_literal(Tuple.to_list(tuple)) do :error -> nil {:ok, list} -> body = Enum.map_join(list, ",", fn {element, string_literal} -> case element do nil -> "" "" -> ~s|""| _ -> string = String.replace(string_literal, "\"", "\\\"") if String.contains?(string, [",", "(", ")"]) do ~s|"#{string}"| else string end end end) "(" <> body <> ")" end end end defimpl Ecto.DevLogger.PrintableParameter, for: Decimal do def to_expression(decimal), do: to_string_literal(decimal) def to_string_literal(decimal), do: Decimal.to_string(decimal) end defimpl Ecto.DevLogger.PrintableParameter, for: Integer do def to_expression(integer), do: to_string_literal(integer) def to_string_literal(integer), do: Integer.to_string(integer) end defimpl Ecto.DevLogger.PrintableParameter, for: Float do def to_expression(float), do: to_string_literal(float) def to_string_literal(float), do: Float.to_string(float) end defimpl Ecto.DevLogger.PrintableParameter, for: Date do def to_expression(date) do date |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(date), do: Date.to_string(date) end defimpl Ecto.DevLogger.PrintableParameter, for: DateTime do def to_expression(date_time) do date_time |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(date_time), do: DateTime.to_string(date_time) end defimpl Ecto.DevLogger.PrintableParameter, for: NaiveDateTime do def to_expression(naive_date_time) do naive_date_time |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(naive_date_time), do: NaiveDateTime.to_string(naive_date_time) end defimpl Ecto.DevLogger.PrintableParameter, for: Time do def to_expression(time) do time |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(time), do: Time.to_string(time) end defimpl Ecto.DevLogger.PrintableParameter, for: BitString do def to_expression(binary) do if String.valid?(binary) do Ecto.DevLogger.Utils.in_string_quotes(binary) else "DECODE('#{Base.encode64(binary)}','BASE64')" end end def to_string_literal(binary) do if String.valid?(binary) do binary end end end defimpl Ecto.DevLogger.PrintableParameter, for: List do def to_expression(list) do case to_string_literal(list) do nil -> "ARRAY[" <> Enum.map_join(list, ",", &Ecto.DevLogger.PrintableParameter.to_expression/1) <> "]" value -> Ecto.DevLogger.Utils.in_string_quotes(value) end end def to_string_literal(list) do case Ecto.DevLogger.Utils.all_to_string_literal(list) do :error -> nil {:ok, elements_with_string_literals} -> if tsvector?(list) do Enum.map_join(elements_with_string_literals, " ", fn {_element, string_literal} -> string_literal end) else body = Enum.map_join(elements_with_string_literals, ",", fn {element, string_literal} -> cond do is_list(element) or is_nil(element) -> string_literal element == "" -> ~s|""| true -> string = String.replace(string_literal, "\"", "\\\"") if String.downcase(string) == "null" or String.contains?(string, [",", "{", "}"]) do ~s|"#{string}"| else string end end end) "{" <> body <> "}" end end end # tsvector is a list of lexemes. # By checking only the first element we assume that the others are also lexemes. defp tsvector?([%{__struct__: Postgrex.Lexeme} | _]) do true end defp tsvector?(_list) do false end end if Code.ensure_loaded?(Postgrex.MACADDR) do defimpl Ecto.DevLogger.PrintableParameter, for: Postgrex.MACADDR do def to_expression(macaddr) do macaddr |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(macaddr) do macaddr.address |> Tuple.to_list() |> Enum.map_join(":", fn value -> value |> Integer.to_string(16) |> String.pad_leading(2, "0") end) end end end if Code.ensure_loaded?(Postgrex.Range) do defimpl Ecto.DevLogger.PrintableParameter, for: Postgrex.Range do def to_expression(range) do range |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(%Postgrex.Range{lower: :empty}), do: "empty" def to_string_literal(%Postgrex.Range{upper: :empty}), do: "empty" def to_string_literal(%Postgrex.Range{} = range) do left_bracket = if range.lower_inclusive, do: "[", else: "(" right_bracket = if range.upper_inclusive, do: "]", else: ")" lower = case range.lower do :unbound -> "" value -> Ecto.DevLogger.PrintableParameter.to_string_literal(value) end upper = case range.upper do :unbound -> "" value -> Ecto.DevLogger.PrintableParameter.to_string_literal(value) end left_bracket <> lower <> "," <> upper <> right_bracket end end end if Code.ensure_loaded?(Postgrex.Multirange) do defimpl Ecto.DevLogger.PrintableParameter, for: Postgrex.Multirange do def to_expression(%Postgrex.Multirange{} = multirange) do multirange |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(%Postgrex.Multirange{ranges: ranges}) do ranges = ranges || [] body = Enum.map_join(ranges, ",", fn %Postgrex.Range{} = range -> Ecto.DevLogger.PrintableParameter.to_string_literal(range) end) "{" <> body <> "}" end end end if Code.ensure_loaded?(Postgrex.Interval) do defimpl Ecto.DevLogger.PrintableParameter, for: Postgrex.Interval do def to_expression(struct), do: Postgrex.Interval.to_string(struct) |> Ecto.DevLogger.Utils.in_string_quotes() def to_string_literal(struct), do: Postgrex.Interval.to_string(struct) end end if Code.ensure_loaded?(Postgrex.INET) do defimpl Ecto.DevLogger.PrintableParameter, for: Postgrex.INET do def to_expression(inet) do inet |> to_string_literal() |> Ecto.DevLogger.Utils.in_string_quotes() end def to_string_literal(inet) do netmask = case inet.netmask do nil -> "" netmask -> "/#{netmask}" end "#{:inet.ntoa(inet.address)}#{netmask}" end end end if Code.ensure_loaded?(Postgrex.Lexeme) do defimpl Ecto.DevLogger.PrintableParameter, for: Postgrex.Lexeme do def to_expression(lexeme) do raise "Invalid parameter: #{lexeme} must be inside a list" end def to_string_literal(lexeme) do word = if String.contains?(lexeme.word, [",", "'", " ", ":"]) do Ecto.DevLogger.Utils.in_string_quotes(lexeme.word) else lexeme.word end case lexeme.positions do [] -> word positions -> positions = Enum.map_join(positions, ",", fn {position, weight} -> if weight in [nil, :D] do Integer.to_string(position) else [Integer.to_string(position), Atom.to_string(weight)] end end) "#{word}:#{positions}" end end end end defimpl Ecto.DevLogger.PrintableParameter, for: Ecto.DevLogger.NumericEnum do def to_expression(enum), do: "#{enum.integer}/*#{enum.atom}*/" def to_string_literal(_numeric_enum), do: nil end