16 Jul 2013, 02:25
Dave_gnome_head_isolated_pragsmall

Dave Thomas (338 posts)

  • The Lists chapter had an exercise about calculating sales tax. We now have the sales information in a file of comma-separated id, ship_to, and amount values. The file looks like this:

    id,ship_to,net_amount
    123,:NC,100.00
    124,:OK,35.50
    125,:TX,24.00
    126,:TX,44.80
    127,:NC,25.00
    128,:MA,10.00
    129,:CA,102.00
    120,:NC,50.00
    

    Write a function that reads and parses this file, and then passes the result to the sales tax function. Remember that the data should be formatted into a keyword list, and that the fields need to be the correct types (so the id field is an integer, and so on).

    You’ll need the library functions File.open, IO.read(file, :line), and IO.stream(file).

A Possible Solution
defmodule SimpleCSV do
  def read(filename) do
    file = File.open!(filename)
    headers = read_headers(IO.read(file, :line))
    Enum.map(IO.stream(file), create_one_row(headers, &1))
  end

  defp read_headers(hdr_line) do
    from_csv_and_map(hdr_line, binary_to_atom(&1))
  end

  defp create_one_row(headers, row_csv) do
    row = from_csv_and_map(row_csv, maybe_convert_numbers(&1))
    Enum.zip(headers, row)
  end

  defp from_csv_and_map(row_csv, mapper) do
    row_csv
    |> String.strip
    |> String.split(%r{,\s*})
    |> Enum.map(mapper)
  end

  defp maybe_convert_numbers(value) do
    cond do
      Regex.match?(%r{^\d+$}, value)           -> binary_to_integer(value)
      Regex.match?(%r{^\d+\.\d+$}, value)      -> binary_to_float(value)
      << ?: :: utf8, name :: binary >> = value -> binary_to_atom(name)
      true -> value                                            
    end
  end
end

defmodule Tax do

  def orders_with_total(orders, tax_rates) do
    orders |> Enum.map(add_total_to(&1, tax_rates))
  end

  def add_total_to(order = [id: _, ship_to: state, net_amount: net], tax_rates) do
    tax_rate = Keyword.get(tax_rates, state, 0)
    tax      = net*tax_rate
    total    = net+tax
    Keyword.put(order, :total_amount, total)
  end

end


tax_rates =  [ NC: 0.075, TX: 0.08 ]

orders = SimpleCSV.read("sales_data.csv")

IO.inspect Tax.orders_with_total(orders, tax_rates)


29 Apr 2014, 18:46
Ernie2_pragsmall

Ernie Miller (4 posts)

For Elixir 0.13, this solution seems to need updates to use captures. For instance:

# previous:
# create_one_row(headers, &1)
&create_one_row(headers, &1)

Also, IO.stream now requires :line as a second parameter.

Enum.map(IO.stream(file, :line), &create_one_row(headers, &1))

Oh, also the sigil for regexps should be changed from % to ~.

14 Nov 2014, 02:11
Generic-user-small

Elliot Finley (11 posts)

I didn’t do a generalized CSV reader as above. This is just specific to this file (i.e. throw away code). Also, I may be getting carried away with the pipe operator :) :

defmodule Tax do
  def do_tax do
    tax_rates = [ NC: 0.075, TX: 0.08 ]

    "StringsAndBinaries-7-data.txt"
    |> read_orders_from_file
    |> calculate_sales_tax(tax_rates)
  end

  def read_orders_from_file(filename) do
    {:ok, file} = File.open(filename, [:read])

    header = IO.read(file, :line)
    |> String.rstrip
    |> String.split(",")
    |> Enum.map(&String.to_atom/1)

    IO.stream(file, :line)
    |> Stream.map(&String.rstrip/1)
    |> Stream.map(&(String.split(&1,",")))
    |> Stream.map(&convert_types/1)
    |> Stream.map(&(List.zip([header, &1])))
    |> Enum.to_list
  end

  defp convert_types([s_int, s_atom, s_float]) do
    [s_int |> String.to_integer,
     s_atom |> String.lstrip(?:) |> String.to_atom,
     s_float |> String.to_float]
  end

  def calculate_sales_tax(orders, tax_rates) do
    orders
    |> Enum.map(&(calculate_tax(&1,tax_rates)))
  end

  defp calculate_tax(order = [id: _, ship_to: ship_to, net_amount: net_amount],
                     tax_rates) do
    tax_rate = Keyword.get(tax_rates, ship_to)
    multiplier = 1 + (tax_rate || 0)
    total_amount = net_amount * multiplier
    Keyword.put(order, :total_amount, total_amount)
  end
end
05 Dec 2014, 21:14
9863_pragsmall

Suraj Kurapati (15 posts)

Here is my solution, using pattern matching on binaries for value parsing:

defmodule Orders do
  def from_file(filename) do
    {[header], orders} = File.stream!(filename)
                          |> Stream.map(&line_to_row/1)
                          |> Enum.split(1) # this consumes ENTIRE stream :'(
    fields = Enum.map(header, &String.to_atom/1)
    orders = Enum.map(orders, &( Enum.zip(fields, row_to_elixir(&1)) ))
  end

  defp line_to_row(line), do: line |> String.rstrip |> String.split(",")

  defp row_to_elixir(values), do: Enum.map(values, &value_to_elixir/1)

  defp value_to_elixir(<< ":", atom::binary >>), do: String.to_atom(atom)
  defp value_to_elixir(number) do
    case Integer.parse(number) do
      {_integer, << ".", _fraction::binary >>} ->
        {float, _rest} = Float.parse(number)
        float
      {integer, _rest} -> integer
      :error -> number # give it back as-is because we couldn't parse it
    end
  end

  def add_tax(orders, tax_rates) do
    for order=[id: _, ship_to: state, net_amount: amount] <- orders do
      tax_rate = Keyword.get(tax_rates, state, 0)
      total_amount = amount * (1 + tax_rate)
      Keyword.put(order, :total_amount, total_amount)
    end
  end
end

And here is how it’s invoked:

iex(1)> tax_rates = [ NC: 0.075, TX: 0.08 ]
[NC: 0.075, TX: 0.08]
iex(2)> Orders.from_file("orders.csv") |> Orders.add_tax(tax_rates)
[[total_amount: 107.5, id: 123, ship_to: :NC, net_amount: 100.0],
 [total_amount: 35.5, id: 124, ship_to: :OK, net_amount: 35.5],
 [total_amount: 25.92, id: 125, ship_to: :TX, net_amount: 24.0],
 [total_amount: 48.384, id: 126, ship_to: :TX, net_amount: 44.8],
 [total_amount: 26.875, id: 127, ship_to: :NC, net_amount: 25.0],
 [total_amount: 10.0, id: 128, ship_to: :MA, net_amount: 10.0],
 [total_amount: 102.0, id: 129, ship_to: :CA, net_amount: 102.0],
 [total_amount: 53.75, id: 120, ship_to: :NC, net_amount: 50.0]]
  You must be logged in to comment