lib/ex_aws/dynamo/encodable.ex

defprotocol ExAws.Dynamo.Encodable do
  @type t :: any

  @doc "Converts an Elixir value into a map tagging the value with its Dynamo type"
  def encode(value, options)
end

defimpl ExAws.Dynamo.Encodable, for: Atom do
  def encode(true, _), do: %{"BOOL" => true}
  def encode(false, _), do: %{"BOOL" => false}
  def encode(nil, _), do: %{"NULL" => true}
  def encode(value, _), do: %{"S" => value |> Atom.to_string()}
end

defimpl ExAws.Dynamo.Encodable, for: Integer do
  def encode(val, _) do
    %{"N" => val |> Integer.to_string()}
  end
end

defimpl ExAws.Dynamo.Encodable, for: Float do
  def encode(val, _) do
    %{"N" => String.Chars.Float.to_string(val)}
  end
end

defimpl ExAws.Dynamo.Encodable, for: HashDict do
  def encode(hashdict, _) do
    %{"M" => ExAws.Dynamo.Encodable.Map.do_encode(hashdict)}
  end
end

defimpl ExAws.Dynamo.Encodable, for: Any do
  defmacro __deriving__(module, struct, options) do
    deriving(module, struct, options)
  end

  def deriving(module, _struct, options) do
    extractor =
      if only = options[:only] do
        quote(do: Map.take(struct, unquote(only)))
      else
        quote(do: :maps.remove(:__struct__, struct))
      end

    quote do
      defimpl ExAws.Dynamo.Encodable, for: unquote(module) do
        def encode(struct, options) do
          ExAws.Dynamo.Encodable.Map.encode(unquote(extractor), options)
        end
      end
    end
  end

  def encode(_, _), do: raise("ExAws.Dynamo.Encodable does not fallback to any")
end

defimpl ExAws.Dynamo.Encodable, for: Map do
  defmacro __deriving__(module, struct, options) do
    ExAws.Dynamo.Encodable.Any.deriving(module, struct, options)
  end

  def encode(map, options) do
    %{"M" => do_encode(map, options)}
  end

  def do_encode(map, only: only) do
    map
    |> Map.take(only)
    |> do_encode
  end

  def do_encode(map, except: except) do
    :maps.without(except, map)
    |> do_encode
  end

  def do_encode(map, _), do: do_encode(map)

  def do_encode(map) do
    Enum.reduce(map, %{}, fn
      {k, v}, map when is_binary(k) ->
        Map.put(map, k, ExAws.Dynamo.Encodable.encode(v, []))

      {k, v}, map ->
        key = String.Chars.to_string(k)
        Map.put(map, key, ExAws.Dynamo.Encodable.encode(v, []))
    end)
  end
end

defimpl ExAws.Dynamo.Encodable, for: BitString do
  def encode(val, _) do
    if String.valid?(val) do
      %{"S" => val}
    else
      %{"B" => Base.encode64(val)}
    end
  end
end

defimpl ExAws.Dynamo.Encodable, for: List do
  alias ExAws.Dynamo.Encodable
  def encode([], _), do: %{"L" => []}

  @doc """
  DynamoDB offers typed sets and L, a generic list of typed attributes.
  """
  def encode(list, _) do
    typed_values =
      for value <- list do
        Encodable.encode(value, [])
      end

    %{"L" => typed_values}
  end
end

defimpl ExAws.Dynamo.Encodable, for: MapSet do
  def encode(mapset, _) do
    cond do
      MapSet.size(mapset) == 0 ->
        raise "Cannot determine a proper data type for an empty MapSet"

      Enum.all?(mapset, &is_number/1) ->
        %{"NS" => number_set_to_list(mapset)}

      Enum.all?(mapset, &is_binary/1) ->
        %{"SS" => MapSet.to_list(mapset)}

      true ->
        raise "All elements in a MapSet must be only numbers or only strings"
    end
  end

  defp number_set_to_list(number_mapset) do
    number_mapset
    |> MapSet.to_list()
    |> Enum.map(fn
      n when is_integer(n) -> Integer.to_string(n)
      n when is_float(n) -> Float.to_string(n)
    end)
  end
end