Scala from the ground up

Putting it together: a sales report

A case class for the rows, groupBy and a sum per group, sortBy for a fixed order, and f"..." with padding so the numbers line up in columns.

Part 12 of 12

Advanced20 min readBack to Courses

The data, as a case class

The report starts with a record: case class Sale(region: String, item: String, amount: Int). One line gives the type, the constructor, equality, copy and a readable toString, so the data model is a single declaration and the list below it is plain literals that anyone can edit.

Reading that list is like reading a table. sales.size counts the rows, sales.map(s => s.amount).sum adds up one column, and maxBy(s => s.amount) picks out the largest row. Three questions about the data, answered without a loop in sight.

groupBy, and a total per group

groupBy(s => s.region) turns the list into a Map from region to the rows in that region. A map over that map then turns each group into a pair of name and sum, written as { case (region, rows) => ... } — the braces with case are how a function over a pair is spelled when the pair has to be taken apart.

The result is a map, so the next step is what the map chapter taught: .toSeq.sortBy(r => r._1) orders it by name and makes the printed order stable. sortBy(r => -r._2) orders by the number instead, largest first, and the minus sign is the simplest way to ask for descending when the values are numbers.

Columns with f"..."

An f in front of the quotes turns interpolation into formatting: f"$region%-8s $sum%6d" prints a name left-aligned in eight characters and a total right-aligned in six. The letter after the percent says which type is being formatted — s for text, d for a whole number, f for a decimal — and the minus sign says which side the padding goes.

f"$pct%5.1f%%" prints one decimal place, and the doubled percent sign is how a literal percent is written in this form. Every line the loops print is the same width, so the columns line up without anyone counting spaces by hand, and the same report comes out on every run.

The report as a pipeline

Read the report from its types. sales is a List[Sale]; groupBy turns it into a Map[String, List[Sale]]; the map over that map turns each value into a number and produces a Map[String, Int]; toSeq changes the outer shape to a sequence of pairs; sortBy orders those pairs. Each stage is a value, and the next stage is written against the shape the previous one produced.

The f in front of the quotes checks the format against the value at compile time. A spec is a percent sign, an optional minus for left alignment, a width, an optional decimal precision, and a conversion letter: s for text, d for a whole number, f for a decimal. %% prints a literal percent, because a single one would start another spec.

The share column needs one extra thought. sum * 100 / total does whole-number division, so a region below the total gets 0 rather than a fraction. Writing 100.0 first makes the multiplication a Double, and the division keeps the decimal. The format then prints one place after the point, and the column lines up because every line has the same width.

One format, one place

The loop that prints the rows and the line that prints the total use the same format, so the columns line up. When that format grows past two fields, give it a name: a small def row(name: String, sum: Int): String = f"$name%-8s $sum%6d" keeps the width in one place, and every caller gets the same columns without repeating the spec.

The function returns a string rather than printing it, which is what lets the caller decide where the line goes — into the report, into a list, or into a test.

Three mistakes this chapter invites: computing a percentage with * 100 / total and getting zero, pairing %d with a Double, and sorting the Map directly instead of calling toSeq first. The first gives a wrong number; the other two are compile errors, because the format type is checked and a map has no sortBy of its own.

Try these next

  • Add a quantity field to Sale and report the total quantity per region.
  • Change the share line to two decimal places and check that the column still lines up.
  • Sort the item table by name instead of by total, then restore the largest-first order.

Rows, groups and a padded line

case class Sale(region: String, item: String, amount: Int)

val sales = List(
  Sale("north", "lamp", 120),
  Sale("south", "lamp", 80),
  Sale("north", "desk", 300)
)

val byRegion = sales.groupBy(s => s.region)
val rows = byRegion.map { case (region, rs) =>
  (region, rs.map(s => s.amount).sum)
}.toSeq.sortBy(r => r._1)

for ((region, sum) <- rows) {
  println(f"$region%-8s $sum%6d")
}

val total = sales.map(s => s.amount).sum
for ((region, sum) <- rows) {
  val pct = sum * 100.0 / total
  println(f"$region%-8s $pct%5.1f%%")
}

A sales report

Run it and read the three blocks. Then add a fourth sale in a region that is not there yet, and check that both tables and the share block pick it up without any other change.

Output
Click "Run" to execute your code

A support ticket report

Run it and check the team totals against the ticket list. Then add a ticket for a new team and confirm that both tables and the share block pick it up unchanged.

Output
Click "Run" to execute your code

The width in %-8s is a minimum, not a maximum: a longer name pushes the rest of the line along rather than being cut off. Pick a width wider than the longest value you expect and the columns stay aligned.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does sales.groupBy(s => s.region) give?