DSC3103 Β· Module 9 of 9

Practical Test 02 (dsc3103_test_02): Milk Star Schema & Analytics

Conquer unknown dimension keys, 2-stage capacity SQL & partition pruning

~57 min to study properly

Let's dissect `dsc3103_test_02` β€” the Milk Cooperative Practical Test! You were given `milk_clean.parquet` (20,000 rows across 6 months, `2026-03` to `2026-08`), `centres.csv` (5 centres), and `farmers.csv` (294 registered farmers).

Checkpoint Β· the examiner asks

In `dsc3103_test_02`, how should `dim_farmer` and `fact_delivery` handle the 6 unregistered `farmer_id`s so all 3 integrity checks pass? Also explain why `days_over_capacity` needs a two-stage aggregation and what happens when a farmer moves centres in July.

In one minute

In test_02, 6 farmers (408 deliveries) are missing from farmers.csv, so add an Unknown member (farmer_key = 0) and COALESCE the join key β€” all 20,000 fact rows survive with no NULL keys. capacity_litres is a DAILY limit: aggregate litres per (centre, date) in a CTE before comparing. Rank top farmers with RANK() OVER (PARTITION BY centre …) excluding Unknown, and partition by month so April queries scan one partition.

Traps examiners look for
  • Inner-joining dim_farmer and silently dropping 408 deliveries.
  • Comparing single deliveries to a daily capacity.
  • Including the Unknown farmer bucket in top-farmer rankings.
Read

Study notes

01The test_02 data and what's hiding in it

dsc3103_test_02 gives you milk_clean.parquet (20,000 deliveries, March–August 2026), centres.csv (5 centres with a daily capacity_litres) and farmers.csv (294 registered farmers). You build dim_date, dim_centre, dim_farmer and fact_delivery, then answer analytics questions in DuckDB.

The traps are in the data, not the SQL syntax:

  1. 300 distinct farmer_ids appear in the deliveries but only 294 are registered β€” 6 farmers, 408 deliveries, have no dimension row.
  2. capacity_litres is a limit per day, but the biggest single delivery is 46.8 L β€” so any row-by-row comparison finds nothing.
  3. A farmer can move to another centre mid-season (July), which changes how "top farmers per centre" should be counted.

02Unknown members, not lost rows

If dim_farmer only contains farmers.csv:

  • an INNER JOIN silently drops the 408 deliveries (2% of the litres disappear from every total);
  • a LEFT JOIN keeps them with a NULL farmer_key, breaking referential integrity and making GROUP BYs show a mysterious NULL group.

Kimball's answer is an explicit Unknown member:

CREATE TABLE dim_farmer AS
SELECT 0 AS farmer_key, -1 AS farmer_id, 'Unknown' AS farmer_name
UNION ALL
SELECT ROW_NUMBER() OVER (ORDER BY farmer_id) AS farmer_key, farmer_id, farmer_name FROM farmers;

CREATE TABLE fact_delivery AS
SELECT d.date_key, c.centre_key, COALESCE(f.farmer_key, 0) AS farmer_key, m.litres, m.litres * m.price_ugx AS revenue_ugx
FROM milk m
LEFT JOIN dim_farmer f USING (farmer_id)
JOIN dim_centre c USING (centre) JOIN dim_date d ON d.date = m.date;

Result: 20,000 fact rows, zero NULL keys, and the 408 deliveries are visible as "Unknown" β€” which is itself a data-quality finding to report.

03Two-stage aggregation for capacity

"How many days was each centre over capacity?" Capacity is daily, so first compute the daily total per centre, then compare:

WITH daily AS (
  SELECT centre_key, date_key, SUM(litres) AS daily_litres
  FROM fact_delivery GROUP BY centre_key, date_key)
SELECT c.centre_name, COUNT(*) AS days_over_capacity
FROM daily d JOIN dim_centre c USING (centre_key)
WHERE d.daily_litres > c.capacity_litres
GROUP BY c.centre_name ORDER BY days_over_capacity DESC;

Kiruhura (950 L/day) was over on 41 days, Masaka 36, Mbale 32, Gulu 28, Mbarara 23. WHERE litres > capacity_litres on single deliveries returns zero β€” a correct query for the wrong question.

04Ranking, partitions and SCD2

Top 3 farmers per centre uses a window function β€” and must exclude the Unknown bucket, which would otherwise win:

SELECT * FROM (
  SELECT c.centre_name, f.farmer_name, SUM(litres) AS total,
         RANK() OVER (PARTITION BY c.centre_name ORDER BY SUM(litres) DESC) AS rnk
  FROM fact_delivery x JOIN dim_farmer f USING (farmer_key) JOIN dim_centre c USING (centre_key)
  WHERE f.farmer_key != 0 GROUP BY 1, 2) WHERE rnk <= 3;

RANK gives ties the same rank (and skips the next); ROW_NUMBER breaks ties arbitrarily.

Partitioning fact_delivery by month writes one Parquet folder per month. A query with WHERE month = '2026-04' reads only that folder (3,201 rows instead of 20,000) β€” partition pruning. A filter on another column, like farmer_key = 17, still reads all six. Check with EXPLAIN ANALYZE.

A farmer moving centres in July is a slowly changing dimension: with SCD Type 2 you keep one dimension row per period (valid_from, valid_to), so June deliveries count for the old centre and August for the new one.

Watch it done

Worked examples

Worked example 1

What each join does to the fact table

milk_clean has 20,000 deliveries; 408 are from unregistered farmers. How many fact rows and NULL farmer_keys result from (a) INNER JOIN, (b) LEFT JOIN, (c) LEFT JOIN + COALESCE with an Unknown member?

    Worked example 2

    Why the naive capacity query returns zero

    Kiruhura's capacity is 950 L/day. On 2026-05-14 it received 31 deliveries averaging 38 L. Was it over capacity, and what does WHERE litres > capacity_litres return for that day?

      Vocabulary

      Key terms

      Why Test 02's Unknown Member pattern is a Kimball classic

      In real agricultural cooperatives, new farmers deliver milk on their first day before their paper registration form is entered into `farmers.csv`. Dropping their deliveries would lose real milk inventory and money β€” which is why Kimball's 'Unknown Member' surrogate key (`0` or `-1`) is standard in every enterprise warehouse.

      Practise

      Quick check

      0 of 8 correct so far. Wrong answers come with an explanation β€” and the buddy can unpack them.

      Q1In `dsc3103_test_02`, `milk_clean.parquet` has 20,000 rows, `farmers.csv` has 294 farmers, and 408 deliveries come from 6 unregistered `farmer_id`s. With an Unknown row in `dim_farmer`, how many rows are in `dim_farmer` and `fact_delivery`?

      Q2`fact_delivery` is partitioned by `month` across 6 months (`2026-03` to `2026-08`). When DuckDB runs `EXPLAIN ANALYZE` for `WHERE month = '2026-04'`, how many Parquet files and rows are scanned?

      Q36 unregistered farmers made 408 deliveries. With an INNER JOIN to dim_farmer, how many of the 20,000 rows survive?

      Q4What is farmer_key for an unregistered farmer's delivery in the Kimball design?

      Q5capacity_litres is…

      Q6Which filter benefits from month partitioning?

      Q7Why exclude farmer_key = 0 from top-farmer rankings?

      Q8A farmer moves centre in July. Which design keeps June litres with the old centre?

      Exam-style

      Written practice

      Short answer5 marks

      How do you model unregistered farmers so fact_delivery keeps all 20,000 rows with no NULL keys?

      Short answer6 marks

      Write the DuckDB query for days_over_capacity per centre and explain why a single-step query is wrong.

      Long answer15 marks

      For test_02, design the star schema (tables, grains, keys), then answer how you would (i) handle unregistered farmers, (ii) compute days over capacity, (iii) rank the top 3 farmers per centre, (iv) speed up April 2026 queries, and (v) handle a farmer who changes centre in July.

      Watch

      Videos

      Fresh picks are chosen by AI every day.
      Hands-on

      Milk Star Schema Warehouse & Two-Stage Analytics Simulator

      5 guided tasks: predict, experiment, explain β€” then challenge yourself. Plus 3 coding exercises you run right in the browser.

      Start the lab