Video course contents16
LESSON 15 · HHY 1.7.0
Project: Build a CSV Report
Validate salary values and summarize active employees by department.
Your progress stays in this browser. No account needed.
Read the companion manual chapter →
Chapters & transcript
Select a timestamp to jump in the video. Expand a title to read the transcript and code.
A report with a clear population
This project turns a small employee CSV into a department summary. Engineering has two active employees with a total salary of three hundred. Research has one active employee with a total of two hundred. A fourth record is inactive and does not belong in this report. Before writing code, notice the business definition: we are summarizing active employees only. Making that population explicit prevents a technically valid transformation from answering the wrong question.
Engineering: 2 employees, total salary 300
Research: 1 employee, total salary 200Units are arbitrary fixture amounts, not real payroll data.
Define input and output
The command accepts an input CSV path and an output JSON path. Like the log project, it checks the argument count before doing any work. The fixture includes a header row, department names, numeric salary text, and an active flag. The output is JSON because it preserves numbers and objects clearly for another program. Start by reading the small fixture yourself and calculating both totals by hand. That gives us an independent expected answer instead of trusting the program to validate itself.
hhy run department-report.hhy fixtures/employees.csv report.jsonRun from the downloaded examples directory.
Parse records, then choose the population
Read lines supplies the CSV parser with an input stream, and the header option gives each record named fields. The active cell is text, so we compare it with the string true rather than the Boolean true used in our JSON API example. This distinction comes from the input format. Filtering happens before salary conversion because our report only validates and summarizes the active population. A whole file quality audit would need a separate policy that also examines inactive records.
let rows = read_lines(path(args[0]))
|> parse_csv({ header: true })
|> where { row -> row.active == "true" }CSV cells are text values.
Convert and validate
Convert each selected salary to a number and reject negative amounts. Return a smaller record containing only the fields the summary needs. Invalid numeric text produces a value error rather than quietly contributing zero. Collect completes this validation stage before the output writing stage begins. That separation is helpful: we do not want to publish a report that looks complete after only part of the input was accepted. The fixture uses simple amounts; production financial calculations need an explicit precision and rounding policy.
|> map { row ->
let salary = row.salary |> to_float
if salary < 0 { throw("salary must be nonnegative") }
return { department: row.department, salary: salary }
} |> collectConversion failures stop this report.
Group the finite records
Convert the validated list to a Stream and group by department. Grouping needs to see the finite input before it can produce the department collections, so it is a materializing step. Each group contains a key, which is the department name, and values, which is the list of employee records in that department. Keep those shapes in mind. Most confusing pipeline errors come from assuming a stage still receives individual employees when it now receives groups of employees.
rows |> stream |> group_by { row -> row.department }Each group has a key and a List of values.
Calculate one summary per group
For each group, the employee count is the length of its values list. To calculate the total, convert that list to a Stream, select salary, and sum the resulting numbers. Return one summary object with the department, count, and total. We are intentionally deriving both measurements from the same filtered population. If you counted employees before filtering but summed after filtering, the report could contain inconsistent columns even though every individual operation executed without an error.
let total = group.values |> stream
|> map { row -> row.salary } |> sum
return { department: group.key,
employees: length(group.values), total_salary: total }Inside the map over groups.
Order, encode, and save
Sort the summaries by department so repeated runs have a stable presentation order. Collect the finite summaries, encode readable JSON, and save the destination file. Open the result and compare the two departments, counts, and totals with your hand calculation. Do not check only that JSON was created. A useful test checks the business result, including the exclusion of the inactive record. Pretty formatting helps a person inspect the data while preserving a format that another program can parse.
|> sort_by({ order: "asc" }) { row -> row.department }
|> collect |> encode_json({ pretty: true })
|> save_text(path(args[1]))Inspect report.json after the successful command.
Make failure visible and practice
Run the same script with invalid dot CSV. Its salary contains the word wrong, so conversion fails and the command exits unsuccessfully. The verification driver checks this negative case as well as the successful totals. For practice, add an active employee in a new department and an inactive employee in Engineering. Predict which count and total change. Then rerun the report. You have now built a complete transformation with explicit population selection, numeric validation, grouping, deterministic output, and a reproducible failure case.
hhy run department-report.hhy fixtures/invalid.csv bad.jsonExpected ValueError; no successful new report.
