Programming with PythonSQL and Relational Databases › Day 86

Hands-on lab — Day 86: SELECT: Filtering, Sorting, and Aggregating

Commands

Setup

cd labs/sections/programming-with-python/day-086-select-filtering-sorting-and-aggregating
sqlite3 --version
python3 --version
bash examples/build_db.sh

Run

sqlite3 -header -column examples/library.db < examples/queries/01-filters.sql
sqlite3 -header -column examples/library.db < examples/queries/02-patterns.sql
sqlite3 -header -column examples/library.db < examples/queries/03-null-traps.sql
sqlite3 -header -column examples/library.db < examples/queries/04-sorting.sql
sqlite3 -header -column examples/library.db < examples/queries/05-aggregates.sql
sqlite3 -header -column examples/library.db < examples/queries/06-group-by.sql
sqlite3 -header -column examples/library.db < examples/queries/07-having.sql
sqlite3 -header -column examples/library.db < examples/queries/08-case-and-functions.sql
python3 examples/groupby_from_scratch.py
sqlite3 examples/library.db < starter/exercises.sql
bash starter/check.sh
sqlite3 examples/library.db < examples/exercise-answers.sql

Test

bash tests/run_tests.sh

File tree

.gitignore
examples/build_db.sh
examples/exercise-answers.sql
examples/groupby_from_scratch.py
examples/queries/01-filters.sql
examples/queries/02-patterns.sql
examples/queries/03-null-traps.sql
examples/queries/04-sorting.sql
examples/queries/05-aggregates.sql
examples/queries/06-group-by.sql
examples/queries/07-having.sql
examples/queries/08-case-and-functions.sql
examples/seed.sql
expected-output/exercise-check.txt
expected-output/FIELDS.md
expected-output/groupby-comparison.txt
expected-output/queries.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/check.sh
starter/exercises.sql
tests/run_tests.sh
troubleshooting.md

Lab README

Day 086 lab — Ask the Database Questions

Lesson

Purpose

Yesterday you learned to put rows into a table. Today you learn to ask the table questions, which is the part you will spend the rest of your career doing.

The lab builds a small library database — 24 books, 12 members, 45 loans — and then walks you through the eight families of question you can ask one table: filter it, match patterns in it, sort it, take the top of it, count it, average it, group it, and filter the groups. Every query is in a file you can run, and every answer is a number you can check against the seed by hand.

Three things make this lab different from a list of SQL examples.

The data has holes in it, on purpose. Four books have no rating. Three have no genre. Two members gave no city. Fifteen loans have no return date, because those books are still out. Every one of those holes is a NULL, and NULL is where SQL stops behaving the way your intuition says it should. returned_on = NULL finds nothing at all. city <> 'Pune' quietly discards the members who never told you their city. AVG(rating) gives 4.16, and the "fix" everybody reaches for gives 3.47 and says nothing about the change.

You build GROUP BY yourself before you use it. examples/groupby_from_scratch.py implements grouping and all five aggregates as a dictionary of accumulators over a list of rows — about twenty lines — and then runs the one SQL statement that replaces them and asserts the two results are identical. After you have written the if rating is not None: line yourself, "AVG ignores NULLs" stops being a rule to memorise and becomes the only decision that was ever available.

Every exercise starts out wrong. The twelve exercises in starter/exercises.sql are not blanks. They are twelve queries that run, print a confident, well-formatted answer, and are wrong — the exact queries people actually write. Your job is to fix each one. bash starter/check.sh scores them.

That last point is the lab's argument in miniature. SQL almost never tells you that you asked the wrong question. It answers the one you actually asked.

Learning objectives

  • Trace a SELECT through its logical evaluation order — FROM, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT/OFFSET — and use that order to predict which clause may refer to what.
  • Filter rows with comparisons, AND/OR/NOT, IN and BETWEEN, and say what the brackets change when the two operators meet.
  • Choose between LIKE and GLOB knowing that one folds case and the other does not, and that they use different wildcards.
  • Apply three-valued logic correctly: IS NULL as the only test for absence, what NULL does inside AND and OR, and why negative filters on nullable columns lose rows without saying so.
  • Sort on several keys, control where NULLs land, and take a deterministic top-N with LIMIT and OFFSET.
  • Distinguish COUNT(*) from COUNT(column) and predict the gap between them.
  • Group rows into buckets — including by an expression — and filter those buckets with HAVING, explaining why WHERE cannot do it.
  • Build grouping and aggregation from scratch in Python and prove it agrees with the SQL.
  • Recognise the two places SQLite is more permissive than standard SQL, and write the portable spelling anyway.

Prerequisites

  • The Day 86 lesson (read it first).
  • Day 85: the relational model, SQLite's architecture and type affinity, and CREATE TABLE / INSERT / a first SELECT. This lab seeds its own database so it runs independently, but it assumes you have met a table before.
  • Days 64–66: files, and the habit of a script that returns an exit code.
  • Comfort running a bash script and reading a Python for loop with a dict.
  • No knowledge of joins is needed. Every query here is over one table; joins are tomorrow.

Supported operating systems

  • macOS — fully supported. All captures were taken on macOS 26.5.2 (Apple Silicon), sqlite3 3.51.0, Python 3.14.0, bash 3.2.57.
  • Linux — fully supported on any distribution with bash, python3 and the sqlite3 shell. On Debian and Ubuntu the shell is packaged separately from the library, so you may need sudo apt install sqlite3.
  • Windows — use WSL and follow the Linux path. The .sql files run unchanged against the native Windows sqlite3 shell, but the three bash scripts do not. That native-Windows path has not been executed on the authoring machine and is described rather than promised; see troubleshooting.md.

SQLite 3.30 or newer is needed for the NULLS LAST spelling in examples/queries/04-sorting.sql. The portable equivalent (ORDER BY rating IS NULL, rating) is shown alongside it and works on any version.

Hardware requirements

Anything. The database is a single file of a few tens of kilobytes, the whole test suite finishes in under a second on the authoring machine, and nothing here holds more than a few dozen rows in memory. No GPU, no network, no disk of consequence.

Required software

  • sqlite3 — the command-line shell (3.51.0 here).
  • python3 — 3.9 or newer (3.14.0 here), for the from-scratch comparison. It uses only the standard library's sqlite3 module.
  • bash — for the three shell scripts (3.2.57 here).

Nothing is installed. See requirements/README.md for the full table and for what is deliberately absent.

Free and open-source options

Every tool here is free, and there is nothing to buy at any point.

SQLite is public domain — not merely open source but explicitly dedicated to the public domain by its author, which is why it ships inside browsers, phones, aircraft and your operating system without anyone negotiating a licence. Python and bash are free software under their own licences (see requirements/README.md).

The lesson's Alternatives section covers the wider field honestly: PostgreSQL and MySQL as free servers when you outgrow a single file, DuckDB as the free column-store for analytics, pandas as the dataframe answer to the same questions, and the paid managed services that run all of these for you. Every query in this lab except two clearly-marked SQLite extensions is standard SQL and runs unchanged on any of them.

Installation

There is nothing to install. Check the three tools and build the database:

cd labs/sections/programming-with-python/day-086-select-filtering-sorting-and-aggregating
sqlite3 --version
python3 --version
bash examples/build_db.sh

build_db.sh deletes any existing examples/library.db and recreates it from examples/seed.sql, so you can run it at any point to get back to a known state. It prints seeded: 24 books, 12 members, 45 loans when it works.

File structure

day-086-select-filtering-sorting-and-aggregating/
├── README.md                       ← you are here
├── metadata.yml
├── .gitignore                      ← the built database is never committed
├── examples/                       ← the worked queries, all runnable
│   ├── seed.sql                    ← 24 books, 12 members, 45 loans, and the holes
│   ├── build_db.sh                 ← rebuilds examples/library.db from the seed
│   ├── groupby_from_scratch.py     ← GROUP BY in plain Python, asserted equal to SQL
│   ├── exercise-answers.sql        ← the model answers (read AFTER trying)
│   └── queries/
│       ├── 01-filters.sql          ← WHERE, AND/OR, IN, BETWEEN
│       ├── 02-patterns.sql         ← LIKE vs GLOB
│       ├── 03-null-traps.sql       ← three-valued logic, and the traps
│       ├── 04-sorting.sql          ← ORDER BY, DISTINCT, LIMIT, OFFSET
│       ├── 05-aggregates.sql       ← COUNT/SUM/AVG/MIN/MAX and NULL
│       ├── 06-group-by.sql         ← buckets, including grouping by an expression
│       ├── 07-having.sql           ← the filter WHERE cannot express
│       └── 08-case-and-functions.sql ← scalar functions, CASE, computed columns
├── starter/                        ← YOUR work
│   ├── exercises.sql               ← 12 queries that run and are wrong
│   └── check.sh                    ← scores them; exits 0 only at 12 out of 12
├── tests/
│   └── run_tests.sh                ← 124 checks on actual result VALUES
├── expected-output/
│   ├── test-run.txt                ← the full captured harness run
│   ├── queries.txt                 ← every example query file and its output
│   ├── groupby-comparison.txt      ← the Python-versus-SQL proof
│   ├── exercise-check.txt          ← the starter scored, and the answer key
│   └── FIELDS.md                   ← what must match, what may differ
├── requirements/
│   ├── requirements.txt
│   └── README.md
├── troubleshooting.md
└── security.md

How to run

All commands are run from this directory.

## 1. The whole thing. Start here.
bash tests/run_tests.sh
echo "exit code: $?"

## 2. Build your own copy of the database to play with.
bash examples/build_db.sh

## 3. Work through the eight query files in order. Read each file BEFORE you run
##    it — every query has a comment saying what it is meant to show.
sqlite3 -header -column examples/library.db < examples/queries/01-filters.sql
sqlite3 -header -column examples/library.db < examples/queries/02-patterns.sql
sqlite3 -header -column examples/library.db < examples/queries/03-null-traps.sql
sqlite3 -header -column examples/library.db < examples/queries/04-sorting.sql
sqlite3 -header -column examples/library.db < examples/queries/05-aggregates.sql
sqlite3 -header -column examples/library.db < examples/queries/06-group-by.sql
sqlite3 -header -column examples/library.db < examples/queries/07-having.sql
sqlite3 -header -column examples/library.db < examples/queries/08-case-and-functions.sql

## 4. Build GROUP BY by hand, then watch one SQL statement replace it.
python3 examples/groupby_from_scratch.py

## 5. Now the work. Twelve queries that run and lie. Score them first, so you
##    can see all twelve are wrong before you touch anything.
bash starter/check.sh

## 6. Fix them, one at a time, re-scoring as you go. Open the file:
##    starter/exercises.sql — each exercise states the required answer and a hint.
bash starter/check.sh

## 7. Only when you are done, compare with the model answers and read WHY.
sqlite3 examples/library.db < examples/exercise-answers.sql

To poke at the database interactively, open the shell and stay in it:

sqlite3 examples/library.db
sqlite> .mode column
sqlite> .headers on
sqlite> .tables
sqlite> .schema books
sqlite> SELECT COUNT(*) FROM loans WHERE returned_on IS NULL;
sqlite> .quit

What the commands do

  • bash tests/run_tests.sh — the harness. It builds its own database under a mktemp -d directory (so it never reads your examples/library.db and cannot be affected by anything you did to it), then runs 124 checks in fourteen sections, then deletes that directory in a trap. Every check compares an actual result value to a number or string worked out from the seed by hand. It prints 124 checks, 0 failure(s). and exits 0, or lists each failing check with the expected and actual values side by side and exits 1.
  • bash examples/build_db.sh — drops the three tables and recreates them from examples/seed.sql. Destructive and idempotent on purpose: run it whenever you want a clean database. It accepts an optional path argument, which is how the harness points it at a throwaway copy.
  • sqlite3 -header -column examples/library.db < examples/queries/NN-*.sql — runs one themed file of worked queries. -header prints column names and -column aligns the output into readable columns; without them the shell prints pipe-separated values, which is better for scripts and worse for reading.
  • python3 examples/groupby_from_scratch.py — runs the four stages (FROM, WHERE, GROUP BY, HAVING) as four Python functions, prints how many rows survive each one, prints the result table, then runs the equivalent single SQL statement and prints that table too. It exits 0 only if the two are identical row for row.
  • sqlite3 examples/library.db < starter/exercises.sql — runs your twelve answers and prints them as exNN|value lines.
  • bash starter/check.sh — runs the same file and compares each value with the required answer, printing a three-column table and N correct, M still wrong. It exits 0 only at twelve out of twelve.
  • sqlite3 examples/library.db < examples/exercise-answers.sql — the model answers, each with a comment explaining what the broken version was actually asking.

Expected output

The harness ends like this — a real captured run, in full in expected-output/test-run.txt:

14. The lab stays offline, stays out of your way, and cleans up
  ok: no URL anywhere in examples/, starter/ or tests/
  ok: nothing under examples/ or starter/ calls sudo
  ok: no stray database in the lab root
  ok: the built database is git-ignored, so it is never committed

124 checks, 0 failure(s).

The NULL section is the one to read closely (expected-output/test-run.txt):

4. NULL and three-valued logic
  ok: NULL = NULL is NULL, not 1 = 
  ok: NULL <> NULL is NULL too = 
  ok: NULL IS NULL is 1 = 1
  ok: NULL AND false is FALSE = 0
  ok: NULL AND true is UNKNOWN = 
  ok: NULL OR true is TRUE = 1
  ok: NULL OR false is UNKNOWN = 
  ok: NOT NULL is UNKNOWN = 
  ok: the trap: returned_on = NULL finds nothing = 0
  ok: the other trap: returned_on <> empty string finds the RETURNED ones = 30
  ok: IS NULL is the only correct test = 15
  ok: naive not-from-Pune loses the members with no city = 8
  ok: the honest version keeps them = 10
  ok: and 8 + 2 Pune members would be 10, not 12 — so the naive query lost 2 = 2

Read the last three lines together. There are 12 members and 2 of them live in Pune, so "not from Pune" must be 10. The obvious query returns 8. Nothing warns you, and 8 is a perfectly plausible number.

The from-scratch comparison (expected-output/groupby-comparison.txt):

The 20 lines of Python:
  FROM      -> 24 rows
  WHERE     -> 20 rows survive
  GROUP BY  -> 6 buckets
  HAVING    -> 3 buckets survive

genre_label  books  rated  avg_rating  min_rating  max_rating  avg_pages 
-----------  -----  -----  ----------  ----------  ----------  ----------
science      7      5      4.3         3.7         4.8         326.285714
mystery      4      4      4.45        4.3         4.6         292.5     
fiction      3      3      4.1         3.5         4.7         443.666667

and then the identical table from one SQL statement, followed by IDENTICAL: 3 rows match exactly.

Note science: 7 books, 5 rated. That two-row gap is the entire NULL lesson in one line of output: the average 4.3 is an average of five numbers, not seven, and only the rated column tells you so.

The untouched starter, scored (expected-output/exercise-check.txt):

Exercise   your answer                required
---------  -------------------------  -------------------------
ex01       0                          15                         WRONG
ex02       8                          10                         WRONG
ex03       0                          2                          WRONG
ex04       2                          4                          WRONG
ex05       3.47                       4.16                       WRONG
ex06       20                         4                          WRONG

Every one of those twelve wrong answers came from a query that ran without a warning. Not one of them raised an error.

expected-output/queries.txt holds the output of all eight query files, and expected-output/FIELDS.md states which values must be identical on your machine and which are expected to differ.

Validation steps

  1. bash tests/run_tests.sh ends with 124 checks, 0 failure(s). and exits 0.
  2. bash examples/build_db.sh prints seeded: 24 books, 12 members, 45 loans, and running it a second time prints the same thing — not doubled counts.
  3. SELECT COUNT(*) FROM loans WHERE returned_on = NULL returns 0 and ... WHERE returned_on IS NULL returns 15. Both run without error.
  4. SELECT COUNT(*) FROM members WHERE city <> 'Pune' returns 8, while there are 12 members and 2 of them are in Pune.
  5. SELECT ROUND(AVG(rating),2) FROM books returns 4.16, and the same query wrapped in COALESCE(rating,0.0) returns 3.47.
  6. SELECT COUNT(*), COUNT(rating) FROM books returns 24 and 20.
  7. SELECT author FROM books WHERE COUNT(*) > 3 GROUP BY author is rejected with Error: in prepare, misuse of aggregate: COUNT(), and the same question written with HAVING returns 3 authors.
  8. GROUP BY genre produces 6 buckets while COUNT(DISTINCT genre) returns 5 — the missing one is the bucket of unclassified books.
  9. python3 examples/groupby_from_scratch.py prints IDENTICAL: 3 rows match exactly. and exits 0.
  10. bash starter/check.sh on the untouched starter prints 0 correct, 12 still wrong. and exits 1. After you have fixed all twelve it prints 12 correct, 0 still wrong. and exits 0.
  11. Deliberately break one: change your fixed exercise 1 back to = NULL and confirm check.sh goes red again and exits non-zero. A scorer you have not seen fail is a scorer you have no reason to trust.

Tests

bash tests/run_tests.sh
echo "exit code: $?"

Expected final line: 124 checks, 0 failure(s). Exit code 0 on success, 1 on any failure.

Two things about this harness are worth knowing before you read it.

It checks values, not exit codes. A test that only asserted "the query ran" would pass on all twelve of the broken starter queries, because all twelve run perfectly. Every check here names the value it expects.

Section 5 exists to stop you making the numbers agree by cheating. The most tempting way to make NULLs stop being annoying is to fill them in — replace the missing ratings with 0.0, the missing return dates with an empty string — and then every simple query "works". Section 5 pins the values that such a fix would change: MIN(rating) must be 3.2 and not 0.0; AVG(rating) must be 4.16 and must not equal the COALESCE-to-zero average of 3.47; SUM over zero rows must be NULL while TOTAL over the same rows is 0.0. If someone "fixes" the seed data, those checks go red.

The harness also pins the two places SQLite is more permissive than standard SQL — a SELECT alias used in WHERE, and a bare column in an aggregate query — so the lesson's claims about portability stay tied to observed behaviour rather than to memory.

Cleanup

rm -f examples/library.db

That is all of it. Nothing was installed, no service was started, no port was opened, nothing was written outside this directory, and the test harness removes its own mktemp -d directory in a trap even if you interrupt it with Ctrl-C.

To reset your exercise work: git checkout -- starter/exercises.sql.

Troubleshooting

See troubleshooting.md. The ones you are most likely to meet: unable to open database file, which is nearly always the wrong working directory; no such table: books, which means a typo in the database name created a fresh empty file rather than failing; a query returning 0 rows when you are certain it should not, which in this lab is almost always a NULL; misuse of aggregate: COUNT(), which is an aggregate in WHERE where it should be in HAVING; and an average that looks too low, which is COALESCE(col, 0) inventing data.

Security notes

See security.md. Short version: this lab opens no socket, needs no credentials, starts no daemon and touches nothing outside its own directory — SQLite has no users and no passwords, so access to the data is exactly filesystem access to one file. The risk in this topic is not here but in the very next thing anyone does with a query, which is to build one by pasting a value into a string; security.md shows why parameter placeholders are the only correct answer and why escaping quotes yourself is not. It also makes a point that belongs to today specifically: publishing aggregates is not automatically anonymising, because a bucket containing one row identifies one person. All names in the seed are fictional and every email address uses the permanently reserved .invalid domain.

Extension exercises

  1. Add the query you actually want. Write one question about this data that none of the eight files answers, then answer it. If it needs data from two tables, you have just discovered why tomorrow is about joins.
  2. Find the smallest bucket. Write a query that returns the grouping key with the fewest rows in it. Then re-read the privacy paragraph in security.md and decide whether that result would be safe to publish if these were real people.
  3. Make the NULL handling explicit. Take 06-group-by.sql query 6.3 and add a column that reports what fraction of each bucket actually has a rating. A summary that does not say how much data it is based on is a summary you cannot act on.
  4. Break the scorer on purpose. Edit starter/check.sh to require 99 for exercise 1, run it, and watch that row go red. One minute, and afterwards you know the green ticks mean something.
  5. Time the OFFSET. Write a loop that pages through the books table with LIMIT 5 OFFSET N for increasing N, and reason about what the engine has to do for each page. With 24 rows you will measure nothing; the point is to predict what happens at 24 million and then check your prediction against the lesson's explanation.
  6. Port it. Take 06-group-by.sql and work out, without running it, which queries would need changing for PostgreSQL. There are two, and both are noted in the files. Then decide whether you want to keep writing the SQLite-only spelling.
  7. Extend the from-scratch script. Add COUNT(DISTINCT author) per bucket to groupby_from_scratch.py and to the SQL, and keep the assertion passing. You will need a set in the accumulator, which is exactly what the engine does.
  • Previous day: Day 85 — the relational model, SQLite, and your first table (labs/sections/programming-with-python/day-085-relational-databases-and-sqlite/).
  • Next day: Day 87 — joins and relationships, which is how you ask questions that span more than one table (labs/sections/programming-with-python/day-087-joins-and-relationships/).
  • Week 13: SQL and Relational Databases (labs/sections/programming-with-python/).

Expected output

FIELDS.md

# What must be true, on any platform

The captures in this directory came from a real run on the authoring machine
(macOS 26.5.2, Apple Silicon, sqlite3 3.51.0, Python 3.14.0, bash 3.2.57). This
file separates the values that are allowed to differ on your machine from the
ones that are not, so you can tell a real failure from a cosmetic difference.

This lab is unusually strict on that second list, and deliberately so. The whole
database is built from `examples/seed.sql`, there is no clock in any query, and
nothing is random. Almost every number below is required to match exactly. If
one of them does not, something is genuinely wrong.

## Values that will differ on your machine, and should

| Value | Why |
| --- | --- |
| The `throwaway database:` path in `test-run.txt` | `tests/run_tests.sh` builds its database under `mktemp -d`, whose directory name is random by design |
| The two version numbers on the second line | Yours will match whatever `sqlite3` and `python3` you have. Everything in this lab works on SQLite 3.30 or newer |
| Absolute paths printed by `examples/build_db.sh` | They contain your checkout location; the captures show `<repo>` where the authoring machine's home directory was |
| Column padding in `-column` output | The shell sizes each column to its widest value, so a longer path or a wider terminal shifts the spacing |

## Values that must be identical, everywhere

| Value | Required |
| --- | --- |
| The final line of `bash tests/run_tests.sh` | `122 checks, 0 failure(s).` and exit code 0 |
| The seed | `seeded: 24 books, 12 members, 45 loans` |
| The four deliberate holes | 4 unrated books, 3 unclassified books, 2 members with no city, 15 loans still out |
| `SELECT COUNT(*) FROM loans WHERE returned_on = NULL` | `0` — the trap, and it never errors |
| `SELECT COUNT(*) FROM loans WHERE returned_on IS NULL` | `15` — the correct form |
| `SELECT ROUND(AVG(rating),2) FROM books` | `4.16` |
| `SELECT ROUND(AVG(COALESCE(rating,0.0)),2) FROM books` | `3.47` — a different question, and the wrong answer to this one |
| `SELECT COUNT(*) FROM members WHERE city <> 'Pune'` | `8`, against 12 members and 2 from Pune — the naive filter loses 2 people |
| `SELECT SUM(rating) FROM books WHERE genre='no-such-genre'` | empty, which is how the shell renders NULL. `TOTAL(...)` over the same rows gives `0.0` |
| Authors with more than three titles | `3`, and the most prolific is `Ada Fenwick` with 5 |
| `SELECT author FROM books WHERE COUNT(*) > 3 GROUP BY author` | Rejected: `Error: in prepare, misuse of aggregate: COUNT()` |
| Genre buckets | `6` with GROUP BY, `5` with `COUNT(DISTINCT genre)` |
| The from-scratch comparison | `FROM 24 rows`, `WHERE 20 rows survive`, `GROUP BY 6 buckets`, `HAVING 3 buckets survive`, then `IDENTICAL: 3 rows match exactly.` and exit 0 |
| `bash starter/check.sh` on the untouched starter | `0 correct, 12 still wrong.` and exit code 1 |
| `examples/exercise-answers.sql` | The twelve values `15, 10, 2, 4, 4.16, 4, Ledger of Tides, 6, Ada Fenwick, 3, 28.0, 4` |

## The one platform note worth stating plainly

`sqlite3` ships with macOS. On Debian and Ubuntu the shell is a separate package
(`sqlite3`) from the library, so a machine with Python's `sqlite3` module working
perfectly can still have no `sqlite3` command. `tests/run_tests.sh` checks for it
first and stops with that instruction rather than failing halfway through.

Older SQLite builds are the only version-sensitive part of this lab, in exactly
two places: `NULLS FIRST` / `NULLS LAST` in `ORDER BY` arrived in **SQLite 3.30
(2019)**, and everything else here has been available for far longer. If your
shell rejects `NULLS LAST`, `ORDER BY rating IS NULL, rating` does the same job
on any version — both forms are shown in `examples/queries/04-sorting.sql`, and
the test suite checks that they agree.

exercise-check.txt

$ bash starter/check.sh          # before you have fixed anything
Exercise   your answer                required
---------  -------------------------  -------------------------
ex01       0                          15                         WRONG
ex02       8                          10                         WRONG
ex03       0                          2                          WRONG
ex04       2                          4                          WRONG
ex05       3.47                       4.16                       WRONG
ex06       20                         4                          WRONG
ex07       The Quiet Algorithm        Ledger of Tides            WRONG
ex08       5                          6                          WRONG
ex09       Daniel Okoro               Ada Fenwick                WRONG
ex10       7                          3                          WRONG
ex11       0                          28.0                       WRONG
ex12       0                          4                          WRONG

0 correct, 12 still wrong.
exit: 1

$ sqlite3 examples/library.db < examples/exercise-answers.sql   # the model answers
ex01|15
ex02|10
ex03|2
ex04|4
ex05|4.16
ex06|4
ex07|Ledger of Tides
ex08|6
ex09|Ada Fenwick
ex10|3
ex11|28.0
ex12|4

groupby-comparison.txt

The 20 lines of Python:
  FROM      -> 24 rows
  WHERE     -> 20 rows survive
  GROUP BY  -> 6 buckets
  HAVING    -> 3 buckets survive

genre_label  books  rated  avg_rating  min_rating  max_rating  avg_pages 
-----------  -----  -----  ----------  ----------  ----------  ----------
science      7      5      4.3         3.7         4.8         326.285714
mystery      4      4      4.45        4.3         4.6         292.5     
fiction      3      3      4.1         3.5         4.7         443.666667

The one SQL statement:

genre_label  books  rated  avg_rating  min_rating  max_rating  avg_pages 
-----------  -----  -----  ----------  ----------  ----------  ----------
science      7      5      4.3         3.7         4.8         326.285714
mystery      4      4      4.45        4.3         4.6         292.5     
fiction      3      3      4.1         3.5         4.7         443.666667

IDENTICAL: 3 rows match exactly.

queries.txt

$ sqlite3 -header -column examples/library.db < examples/queries/01-filters.sql
--- 1.1 science books published this century, longest first
title                   published_year  pages
----------------------  --------------  -----
Grammar of Machines     2011            480  
The Paper Observatory   2009            366  
The Glasshouse Problem  2016            344  
Eleven Ways to Fail     2020            302  
The Weather in Numbers  2013            288  
The Quiet Algorithm     2021            264  
Notes Toward a Machine  2024            240  

--- 1.2 two genres AND a year, with the brackets that make it mean that
title                   genre    published_year
----------------------  -------  --------------
The Glasshouse Problem  science  2016          
Eleven Ways to Fail     science  2020          
The Quiet Algorithm     science  2021          
Coasts of Elsewhere     history  2023          
Notes Toward a Machine  science  2024          

--- 1.2b the same words without brackets: AND binds tighter than OR, so it means
         science-of-any-year OR history-since-2015 — a different question
with_brackets
-------------
5            
without_brackets
----------------
8               

--- 1.3 IN is the readable form of a chain of ORs
title                   genre  
----------------------  -------
A Map of Small Errors   mystery
The Anchor Room         mystery
The Second Archive      mystery
The Silent Archive      mystery
A Winter Grammar        poetry 
Field Notes on Rain     poetry 
Poems for a Dry Season  poetry 

--- 1.4 BETWEEN is inclusive at BOTH ends
title                    published_year
-----------------------  --------------
The Long Instrument      2015          
The Glasshouse Problem   2016          
Continental Drift Blues  2017          
The Silent Archive       2018          

--- 1.5 the same range written out, to prove BETWEEN includes the edges
between_count
-------------
4            
explicit_count
--------------
4             

--- 1.6 NOT IN: everything except two genres (watch what happens to NULL genres)
not_in_count
------------
14          

$ sqlite3 -header -column examples/library.db < examples/queries/02-patterns.sql
--- 2.1 LIKE: every title containing "archive", in any case
title             
------------------
The Second Archive
The Silent Archive

--- 2.2 GLOB with the same intent, lower case — case-sensitive, so it finds nothing
glob_lowercase_matches
----------------------
0                     

--- 2.3 GLOB spelled the way the data is spelled
title             
------------------
The Second Archive
The Silent Archive

--- 2.4 the underscore matches exactly one character — five of them, for "Quiet"
title              
-------------------
The Quiet Algorithm

--- 2.4b four underscores is one too few, and the result is silence, not an error
four_underscores
----------------
0               

--- 2.5 GLOB character classes, which LIKE has no equivalent for
title                 
----------------------
A Map of Small Errors 
A Winter Grammar      
Nine Rivers           
Notes Toward a Machine

--- 2.6 anchored prefix search: authors whose surname starts with a letter range
author        
--------------
Ada Fenwick   
Kofi Mensah   
Marta Iglesias
Tomas Berg    

$ sqlite3 -header -column examples/library.db < examples/queries/03-null-traps.sql
--- 3.1 the truth, straight from the engine: NULL = NULL is not true
null_eq_null  null_ne_null  null_is_null  null_and_false  null_and_true  null_or_true  null_or_false  not_null
------------  ------------  ------------  --------------  -------------  ------------  -------------  --------
                            1             0                              1                                    

--- 3.2 THE TRAP: books still on loan, written the wrong way
wrong_still_out
---------------
0              

--- 3.3 the same wrong idea in its other popular disguise
also_wrong_still_out
--------------------
30                  

--- 3.4 the only correct test
still_out
---------
15       

--- 3.5 the mirror trap: "not from Pune" quietly drops members with no city
naive_not_pune
--------------
8             
honest_not_pune
---------------
10             
total_members
-------------
12           

--- 3.6 IS NOT DISTINCT FROM: the NULL-safe equality, spelled IS in SQLite
unrated_via_is
--------------
4             

--- 3.7 COALESCE and IFNULL substitute a value for the absence of one
title                     raw_rating  rating_as_zero  genre_filled
------------------------  ----------  --------------  ------------
The Quiet Algorithm                   0.0             science     
Winter Counting           4.0         4.0             unclassified
Field Notes on Rain                   0.0             poetry      
Small Gods of Arithmetic  4.9         4.9             unclassified
Coasts of Elsewhere                   0.0             history     
Notes Toward a Machine                0.0             science     
Wintering Grounds         3.4         3.4             unclassified

--- 3.8 NULLIF is the inverse: turn a sentinel value back into NULL
zero_becomes_null  five_stays_five
-----------------  ---------------
                   5              

$ sqlite3 -header -column examples/library.db < examples/queries/04-sorting.sql
--- 4.1 two sort keys: genre ascending, then rating descending inside each genre
genre    title                    rating
-------  -----------------------  ------
fiction  The Long Instrument      4.7   
fiction  Nine Rivers              4.1   
fiction  Continental Drift Blues  3.5   
fiction  Ledger of Tides          3.2   
history  The Lost Cartographers   4.2   
history  Salt and Longitude       3.9   
mystery  The Second Archive       4.6   
mystery  The Silent Archive       4.5   
mystery  A Map of Small Errors    4.4   
mystery  The Anchor Room          4.3   
poetry   Poems for a Dry Season   4.2   
poetry   A Winter Grammar         3.8   

--- 4.2 where NULLs sort in SQLite: first when ascending, last when descending
title                   rating
----------------------  ------
The Quiet Algorithm           
Field Notes on Rain           
Coasts of Elsewhere           
Notes Toward a Machine        
Ledger of Tides         3.2   
Wintering Grounds       3.4   

--- 4.3 the same column descending — the NULLs move to the end
title                     rating
------------------------  ------
Small Gods of Arithmetic  4.9   
Grammar of Machines       4.8   
The Long Instrument       4.7   
The Glasshouse Problem    4.6   
The Second Archive        4.6   
The Silent Archive        4.5   

--- 4.4 forcing NULLs last regardless of direction
title                    rating
-----------------------  ------
Ledger of Tides          3.2   
Wintering Grounds        3.4   
Continental Drift Blues  3.5   
Eleven Ways to Fail      3.7   
A Winter Grammar         3.8   
Salt and Longitude       3.9   

--- 4.5 SQLite also spells it out: NULLS LAST
title                    rating
-----------------------  ------
Ledger of Tides          3.2   
Wintering Grounds        3.4   
Continental Drift Blues  3.5   
Eleven Ways to Fail      3.7   
A Winter Grammar         3.8   
Salt and Longitude       3.9   

--- 4.6 ORDER BY can use a SELECT alias, on every engine, because it runs after SELECT
title                    reading_minutes
-----------------------  ---------------
The Lost Cartographers   1224           
Salt and Longitude       1056           
The Long Instrument      1024           
Grammar of Machines      960            
Coasts of Elsewhere      910            
Continental Drift Blues  842            

--- 4.6b SQLite ALSO allows the alias in WHERE, as an extension. Standard SQL does
         not, and PostgreSQL rejects it. Portable code repeats the expression.
accepted_by_sqlite
------------------
6                 
the_portable_spelling
---------------------
6                    

--- 4.7 DISTINCT removes duplicate ROWS, not duplicate values in one column
author        
--------------
Ada Fenwick   
Daniel Okoro  
Hana Sato     
Kofi Mensah   
Marta Iglesias
Priya Raman   
Tomas Berg    

--- 4.8 DISTINCT over two columns keeps a row per distinct PAIR
author          genre  
--------------  -------
Ada Fenwick            
Ada Fenwick     science
Daniel Okoro    fiction
Daniel Okoro    science
Hana Sato              
Hana Sato       poetry 
Hana Sato       science
Kofi Mensah            
Kofi Mensah     fiction
Marta Iglesias  fiction
Marta Iglesias  mystery
Marta Iglesias  science
Priya Raman     mystery
Priya Raman     poetry 
Tomas Berg      history

--- 4.9 top-N: LIMIT without ORDER BY is a coin toss, so always pair them
title                     rating
------------------------  ------
Small Gods of Arithmetic  4.9   
Grammar of Machines       4.8   
The Long Instrument       4.7   
The Glasshouse Problem    4.6   
The Second Archive        4.6   

--- 4.10 page two of the same list, via OFFSET
title                   rating
----------------------  ------
The Silent Archive      4.5   
A Map of Small Errors   4.4   
The Paper Observatory   4.4   
The Anchor Room         4.3   
Poems for a Dry Season  4.2   

$ sqlite3 -header -column examples/library.db < examples/queries/05-aggregates.sql
--- 5.1 COUNT(*) counts ROWS; COUNT(column) counts NON-NULL VALUES in that column
rows_total  rows_with_a_rating  rows_with_a_genre  unrated_books
----------  ------------------  -----------------  -------------
24          20                  21                 4            

--- 5.2 COUNT(DISTINCT column) counts distinct non-NULL values
author_cells  distinct_authors  distinct_genres_excluding_null
------------  ----------------  ------------------------------
24            7                 5                             

--- 5.3 AVG over a column with NULLs divides by the NON-NULL count
avg_ignoring_nulls  same_thing_by_hand  avg_if_nulls_counted_as_zero
------------------  ------------------  ----------------------------
4.16                4.16                3.4667                      

--- 5.4 and the version people mean when they "fix" the NULLs — a different number
avg_with_nulls_as_zero
----------------------
3.4667                

--- 5.5 SUM of an all-NULL set is NULL, not 0. TOTAL is the same sum returning 0.0
sum_of_no_rows  total_of_no_rows  matching_rows
--------------  ----------------  -------------
                0.0               0            

--- 5.6 MIN and MAX also skip NULLs entirely
lowest_rating  highest_rating  earliest_year  latest_year
-------------  --------------  -------------  -----------
3.2            4.9             1988           2025       

--- 5.7 an aggregate over zero rows still returns exactly one row
n  avg_rating  longest
-  ----------  -------
0                     

$ sqlite3 -header -column examples/library.db < examples/queries/06-group-by.sql
--- 6.1 how many books per genre, commonest first
genre    n
-------  -
science  7
fiction  4
mystery  4
         3
history  3
poetry   3

--- 6.2 GROUP BY puts all the NULLs in ONE bucket — the one place NULLs are treated as equal
genre_label     n
--------------  -
science         7
fiction         4
mystery         4
(unclassified)  3
history         3
poetry          3

--- 6.3 two aggregates per bucket, and the COUNT gap that reveals the NULLs
genre_label     books  rated  avg_rating
--------------  -----  -----  ----------
science         7      5      4.3       
fiction         4      4      3.875     
mystery         4      4      4.45      
(unclassified)  3      3      4.1       
history         3      2      4.05      
poetry          3      2      4.0       

--- 6.4 grouping by an EXPRESSION rather than a column: books per decade
decade  n
------  -
1980    1
1990    2
2000    4
2010    9
2020    7

--- 6.5 grouping by two keys gives one row per combination that actually occurs
author          genre_label     n
--------------  --------------  -
Ada Fenwick     (unclassified)  1
Ada Fenwick     science         4
Daniel Okoro    fiction         1
Daniel Okoro    science         1
Hana Sato       (unclassified)  1
Hana Sato       poetry          2
Hana Sato       science         1
Kofi Mensah     (unclassified)  1
Kofi Mensah     fiction         2
Marta Iglesias  fiction         1
Marta Iglesias  mystery         1
Marta Iglesias  science         1
Priya Raman     mystery         3
Priya Raman     poetry          1
Tomas Berg      history         3

--- 6.6 the loans table: who borrows most
member_id  loans_taken
---------  -----------
1          6          
2          4          
3          4          
4          4          
5          4          

--- 6.7 an aggregate over a CASE is how you count a subset inside a bucket
member_id  loans_taken  still_out  returned
---------  -----------  ---------  --------
2          4            2          2       
3          4            2          2       
5          4            2          2       
6          3            2          1       
7          4            2          2       
1          6            1          5       
8          3            1          2       
10         3            1          2       
11         3            1          2       
12         3            1          2       
4          4            0          4       
9          4            0          4       

--- 6.8 WHERE runs BEFORE GROUP BY, so this counts only the loans of 2026 Q1
member_id  q1_loans
---------  --------
1          5       
2          4       
3          3       
4          3       
5          3       

$ sqlite3 -header -column examples/library.db < examples/queries/07-having.sql
--- 7.1 authors with more than three books in the catalogue
author       titles
-----------  ------
Ada Fenwick  5     
Hana Sato    4     
Priya Raman  4     

--- 7.2 the attempt WHERE cannot make (kept as a comment, because it is an error)
     see the comment in this file, and section 3 of tests/run_tests.sh

--- 7.3 WHERE and HAVING in the same query, each doing its own job
genre    n  avg_rating
-------  -  ----------
science  7  4.3       
mystery  4  4.45      
fiction  3  4.1       
         2  3.7       
history  2  4.2       
poetry   2  4.2       

--- 7.4 HAVING on an aggregate that is not in the SELECT list at all
genre  
-------
fiction
history

--- 7.5 books borrowed more than twice — the classic popularity query
book_id  times_borrowed
-------  --------------
6        7             
1        5             
2        4             
8        4             
13       3             
19       3             
23       3             

--- 7.6 HAVING on an expression built from TWO aggregates: two or more books still out
member_id  loans_taken  still_out
---------  -----------  ---------
2          4            2        
3          4            2        
5          4            2        
6          3            2        
7          4            2        

--- 7.7 SQLite lets a bare column ride along in an aggregate query; most engines do not
genre    title                n
-------  -------------------  -
         Winter Counting      3
fiction  Nine Rivers          4
history  Salt and Longitude   3
mystery  The Silent Archive   4
poetry   Field Notes on Rain  3
science  Grammar of Machines  7
     the title above is ONE arbitrary row from each bucket, not a summary.
     PostgreSQL rejects this query outright. Do not rely on it.

$ sqlite3 -header -column examples/library.db < examples/queries/08-case-and-functions.sql
--- 8.1 a computed column with an alias
title                   pages  evenings_needed
----------------------  -----  ---------------
The Lost Cartographers  612    2.45           
Salt and Longitude      528    2.11           
The Long Instrument     512    2.05           
Grammar of Machines     480    1.92           
Coasts of Elsewhere     455    1.82           

--- 8.2 the string functions worth knowing
initial  title_length  genre_lower  title_without_the         trimmed
-------  ------------  -----------  ------------------------  -------
P        18            mystery      Silent Archive            padded 
A        24                         Small Gods of Arithmetic  padded 

--- 8.3 concatenation is || in SQL, not + and not a function
citation                         
---------------------------------
The Silent Archive (Priya Raman) 
Grammar of Machines (Ada Fenwick)
Salt and Longitude (Tomas Berg)  

--- 8.4 numeric and null-handling scalars
abs_value  rounded  cast_then_add  rating_type  null_type
---------  -------  -------------  -----------  ---------
7          3.142    43             null         null     

--- 8.5 date functions: SQLite stores dates as text and reads them with these
borrowed_on  month_bucket  days_held
-----------  ------------  ---------
2026-01-05   2026-01       14.0     
2026-01-05   2026-01       28.0     
2026-01-11   2026-01       14.0     
2026-01-18   2026-01       14.0     
2026-01-20   2026-01       7.0      

--- 8.6 CASE turns a value into a label — the SQL equivalent of if/elif/else
title                   rating  band     
----------------------  ------  ---------
The Silent Archive      4.5     excellent
Grammar of Machines     4.8     excellent
Salt and Longitude      3.9     fair     
The Quiet Algorithm             unrated  
Nine Rivers             4.1     good     
A Map of Small Errors   4.4     good     
Ledger of Tides         3.2     poor     
The Glasshouse Problem  4.6     excellent
Winter Counting         4.0     good     
The Lost Cartographers  4.2     good     

--- 8.7 GROUP BY over a CASE expression: a histogram of rating bands
band       n
---------  -
good       8
excellent  6
fair       4
unrated    4
poor       2

--- 8.8 the WHEN order matters: put the NULL branch first or it never fires
mislabelled_as_poor
-------------------
4                  

test-run.txt

Day 086 — Ask the Database Questions
sqlite3 3.51.0, Python 3.14.0
throwaway database: /var/folders/7j/4qzljp553ndfjm_y6zbygsz00000gn/T/tmp.3kGmf2n6OT/library.db

1. The seed builds, and builds the same thing every time
  ok: examples/build_db.sh created the database
  ok: books = 24
  ok: members = 12
  ok: loans = 45
  ok: the deliberate holes: unrated books = 4
  ok: the deliberate holes: unclassified books = 3
  ok: the deliberate holes: members with no city = 2
  ok: the deliberate holes: loans still out = 15
  ok: rebuilding gives the same 24 books, not 48 = 24

2. WHERE: comparisons, boolean operators, IN, BETWEEN
  ok: science books from 2000 onwards = 7
  ok: BETWEEN 2015 AND 2018 includes both endpoints = 4
  ok: the same range spelled out with >= and <= = 4
  ok: IN over two genres = 7
  ok: brackets change the meaning: (A OR B) AND C = 5
  ok: AND binds tighter than OR: A OR (B AND C) = 8
  ok: NOT IN silently excludes the NULL genres too = 14

3. LIKE and GLOB really do differ on case
  ok: LIKE %archive% folds case = 2
  ok: GLOB *archive* does not = 0
  ok: GLOB *Archive* spelled as stored = 2
  ok: LIKE underscore matches exactly one character = The Quiet Algorithm
  ok: one underscore too few matches nothing, and does not error = 0
  ok: GLOB character classes have no LIKE equivalent = 4

4. NULL and three-valued logic
  ok: NULL = NULL is NULL, not 1 = 
  ok: NULL <> NULL is NULL too = 
  ok: NULL IS NULL is 1 = 1
  ok: NULL AND false is FALSE = 0
  ok: NULL AND true is UNKNOWN = 
  ok: NULL OR true is TRUE = 1
  ok: NULL OR false is UNKNOWN = 
  ok: NOT NULL is UNKNOWN = 
  ok: the trap: returned_on = NULL finds nothing = 0
  ok: the other trap: returned_on <> empty string finds the RETURNED ones = 30
  ok: IS NULL is the only correct test = 15
  ok: naive not-from-Pune loses the members with no city = 8
  ok: the honest version keeps them = 10
  ok: and 8 + 2 Pune members would be 10, not 12 — so the naive query lost 2 = 2

5. The NULL traps must NOT be 'fixed' by inventing data
  ok: AVG(rating) must not equal the COALESCE-to-zero average (is not 3.47)
  ok: AVG(rating) ignores the 4 NULLs = 4.16
  ok: COALESCE to zero gives a different, wrong answer = 3.47
  ok: SUM over zero matching rows is NULL, not 0 = 
  ok: TOTAL over the same zero rows is 0.0 = 0.0
  ok: an aggregate over zero rows still returns exactly one row = 1
  ok: MIN(rating) is a real rating, not an invented zero = 3.2

6. ORDER BY, DISTINCT, LIMIT and OFFSET
  ok: ascending puts NULLs first in SQLite = 
  ok: descending puts them last = 4.9
  ok: NULLS LAST overrides the ascending default = 3.2
  ok: so does ORDER BY rating IS NULL, rating = 3.2
  ok: two sort keys: first key wins, so this is the top FICTION book = The Long Instrument
  ok: swap the keys and you get the best book overall instead = Grammar of Machines
  ok: the top MYSTERY needs the genre in the WHERE, not the ORDER BY = The Second Archive
  ok: ORDER BY may use a SELECT alias — standard SQL, works everywhere = The Lost Cartographers
  ok: SQLite accepts a SELECT alias in WHERE, as an extension = 6
  ok: the portable spelling gives the same six rows = 6
  ok: DISTINCT over one column: distinct authors = 7
  ok: DISTINCT over two columns keeps one row per PAIR = 15
  ok: top-N with a deterministic tie-break = Small Gods of Arithmetic
  ok: OFFSET 5 starts page two of the same list = The Silent Archive

7. Aggregates, and what NULL does to each
  ok: COUNT(*) counts rows = 24
  ok: COUNT(rating) counts values = 20
  ok: COUNT(genre) counts values = 21
  ok: COUNT(DISTINCT genre) skips NULL = 5
  ok: COUNT(DISTINCT author) = 7
  ok: AVG is SUM over the NON-NULL count = 1
  ok: MIN(published_year) = 1988
  ok: MAX(published_year) = 2025

8. GROUP BY
  ok: one bucket per distinct genre, NULLs together in one more = 6
  ok: the science bucket has 7 books = 7
  ok: the unclassified bucket has 3 = 3
  ok: grouping by an expression: books published in the 2010s = 9
  ok: grouping by two keys gives one row per combination that occurs = 15
  ok: the busiest borrower took 6 loans = 6
  ok: SUM over a CASE counts a subset inside each bucket = 15
  ok: WHERE runs before GROUP BY: Q1 loans for member 1 = 5

9. HAVING — the filter WHERE cannot express
  ok: authors with more than three titles = 3
  ok: the most prolific of them = Ada Fenwick
  ok: WHERE COUNT(*) > 3 is rejected: Error: in prepare, misuse of aggregate: COUNT()
  SELECT author FROM books WHERE COUNT(*) > 3 GROUP BY author;
                   error here ---^
  ok: WHERE and HAVING together: post-2000 genres with 2+ books = 6
  ok: HAVING on an aggregate absent from the SELECT list = 2
  ok: books borrowed more than twice = 7
  ok: the most borrowed book was taken out 7 times = 7
  ok: HAVING over two aggregates: members with 2+ books still out = 5

10. Scalar functions and CASE
  ok: || concatenates = The Silent Archive (Priya Raman)
  ok: a scalar function on NULL returns NULL = 
  ok: TYPEOF names the storage class of the value in the row = null
  ok: JULIANDAY subtraction gives real elapsed days = 28.0
  ok: subtracting the raw TEXT dates confidently returns nonsense = 0
  ok: STRFTIME buckets a stored date by month = 2026-01
  ok: GROUP BY over a CASE: the 'good' band = 8
  ok: with the NULL branch first, 4 books are 'unrated' = 4
  ok: with the NULL branch, 'poor' holds the 6 genuinely low-rated books = 6
  ok: without it, 'poor' swells to 10 as the 4 unrated books fall through = 10

11. Every example query file runs against a fresh database
  ok: examples/queries/01-filters.sql runs clean
  ok: examples/queries/02-patterns.sql runs clean
  ok: examples/queries/03-null-traps.sql runs clean
  ok: examples/queries/04-sorting.sql runs clean
  ok: examples/queries/05-aggregates.sql runs clean
  ok: examples/queries/06-group-by.sql runs clean
  ok: examples/queries/07-having.sql runs clean
  ok: examples/queries/08-case-and-functions.sql runs clean

12. The from-scratch GROUP BY agrees with the one-line SQL
  ok: groupby_from_scratch.py exits 0
  ok: the Python accumulators and the SQL agree on all 3 rows
  ok: the pipeline it prints: FROM 24 rows =   FROM      -> 24 rows
  ok: WHERE keeps 20 =   WHERE     -> 20 rows survive
  ok: GROUP BY makes 6 buckets =   GROUP BY  -> 6 buckets
  ok: HAVING leaves 3 =   HAVING    -> 3 buckets survive

13. The exercises: the answer key is right and the starter is wrong
  ok: the answer key emits 12 labelled lines = 12
  ok: the starter emits the same 12 labels = 12
  ok: answer ex01 = 15
  ok: answer ex02 = 10
  ok: answer ex03 = 2
  ok: answer ex04 = 4
  ok: answer ex05 = 4.16
  ok: answer ex06 = 4
  ok: answer ex07 = Ledger of Tides
  ok: answer ex08 = 6
  ok: answer ex09 = Ada Fenwick
  ok: answer ex10 = 3
  ok: answer ex11 = 28.0
  ok: answer ex12 = 4
  ok: all 12 starter queries return a WRONG answer before you fix them = 12

14. The lab stays offline, stays out of your way, and cleans up
  ok: no URL anywhere in examples/, starter/ or tests/
  ok: nothing under examples/ or starter/ calls sudo
  ok: no stray database in the lab root
  ok: the built database is git-ignored, so it is never committed

124 checks, 0 failure(s).

Source files

.gitignore (109 bytes)
# The database is BUILT, not stored. Rebuild it any time with:
#   bash examples/build_db.sh
library.db
*.db
examples/build_db.sh (606 bytes)
#!/usr/bin/env bash
# Rebuild library.db from examples/seed.sql.
#
# Run from the lab directory:
#   bash examples/build_db.sh
#
# The build is destructive on purpose: it deletes the existing library.db and
# recreates it, so every exercise in this lab starts from the same rows no
# matter what you did to the database in between. That is the whole reason the
# seed is a file and not something you typed once.
set -eu

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
db="${1:-${lab_dir}/examples/library.db}"

rm -f "${db}"
sqlite3 "${db}" < "${lab_dir}/examples/seed.sql"
echo "built: ${db}"
examples/exercise-answers.sql (3611 bytes)
-- Day 086 lab — the model answers to starter/exercises.sql.
--
-- Run:  sqlite3 examples/library.db < examples/exercise-answers.sql
--
-- Read this AFTER you have tried the exercises, not before. Each answer below
-- is one line of the form exNN|value, the same shape starter/exercises.sql
-- emits, so `bash starter/check.sh` can compare the two directly.
--
-- The point of each fix is written above it. In every single case the broken
-- version ran without complaint and returned a plausible number: that is the
-- thing to take away from this file.

.mode list
.headers off

-- 1. `= NULL` is UNKNOWN for every row, so the broken version counted nothing.
--    IS NULL is the only test for absence.
SELECT 'ex01|' || (
  SELECT COUNT(*) FROM loans WHERE returned_on IS NULL
);

-- 2. `city <> 'Pune'` is UNKNOWN where city is NULL, and WHERE keeps only TRUE,
--    so the two members with no city vanished from a question they belong in.
SELECT 'ex02|' || (
  SELECT COUNT(*) FROM members WHERE city IS NULL OR city <> 'Pune'
);

-- 3. GLOB is case-sensitive; LIKE folds case for ASCII letters. Same intent,
--    different matcher.
SELECT 'ex03|' || (
  SELECT COUNT(*) FROM books WHERE title LIKE '%archive%'
);

-- 4. BETWEEN is inclusive at both ends; the strict inequalities dropped 2015
--    and 2018 silently.
SELECT 'ex04|' || (
  SELECT COUNT(*) FROM books WHERE published_year BETWEEN 2015 AND 2018
);

-- 5. AVG already ignores NULLs. COALESCE(rating, 0.0) invents four ratings of
--    zero and drags the average down by more than half a point.
SELECT 'ex05|' || (
  SELECT ROUND(AVG(rating), 2) FROM books
);

-- 6. COUNT(*) counts rows; COUNT(rating) counts non-NULL ratings. The gap
--    between them is exactly the number of unrated books.
SELECT 'ex06|' || (
  SELECT COUNT(*) - COUNT(rating) FROM books
);

-- 7. Ascending order puts NULLs first in SQLite, so the broken version returned
--    a book with no rating as the worst-rated book.
SELECT 'ex07|' || (
  SELECT title FROM books WHERE rating IS NOT NULL ORDER BY rating ASC LIMIT 1
);

-- 8. COUNT(DISTINCT genre) skips NULL. GROUP BY collects all the NULLs into one
--    bucket, which is the bucket the question asked about.
SELECT 'ex08|' || (
  SELECT COUNT(*) FROM (SELECT genre FROM books GROUP BY genre)
);

-- 9. ORDER BY defaults to ASC, which answers the opposite question. DESC, and a
--    tie-break so the answer is deterministic.
SELECT 'ex09|' || (
  SELECT author FROM books GROUP BY author ORDER BY COUNT(*) DESC, author ASC LIMIT 1
);

-- 10. "How many titles this author has" is a property of the GROUP, so only
--     HAVING can filter on it. WHERE runs before the groups exist.
SELECT 'ex10|' || (
  SELECT COUNT(*) FROM (
    SELECT author FROM books GROUP BY author HAVING COUNT(*) > 3
  )
);

-- 11. Both columns are TEXT. Subtracting them coerces each string to the number
--     at its front — 2026 minus 2026 — and confidently returns 0.
SELECT 'ex11|' || (
  SELECT JULIANDAY(returned_on) - JULIANDAY(borrowed_on) FROM loans WHERE loan_id = 2
);

-- 12. `rating >= 4.5` is UNKNOWN when rating is NULL, so every unrated book fell
--     through to the ELSE and was labelled 'poor'. The NULL branch has to come
--     first, because CASE stops at the first WHEN that is TRUE.
SELECT 'ex12|' || (
  SELECT COUNT(*) FROM (
    SELECT CASE
             WHEN rating IS NULL THEN 'unrated'
             WHEN rating >= 4.5  THEN 'excellent'
             WHEN rating >= 4.0  THEN 'good'
             ELSE                     'poor'
           END AS band
    FROM books
  )
  WHERE band = 'unrated'
);
examples/groupby_from_scratch.py (7755 bytes)
#!/usr/bin/env python3
"""Build GROUP BY and the aggregates by hand, then check SQL agrees.

Run from the lab directory, after `bash examples/build_db.sh`:

    python3 examples/groupby_from_scratch.py

An explicit database path may be passed as the one optional argument, which is
how tests/run_tests.sh points it at a throwaway copy.

The point is not that the Python is better. It is that after you have written
the accumulator loop once, the one-line SQL stops being magic: you know exactly
which dictionary it is filling in, and you know exactly why AVG skips NULLs —
because you had to decide that yourself, on line 60-ish, and there was no other
sensible choice available.

Nothing here imports anything outside the standard library.
"""

from __future__ import annotations

import sqlite3
import sys
from pathlib import Path

LAB_DIR = Path(__file__).resolve().parent.parent
DB_PATH = LAB_DIR / "examples" / "library.db"


# --------------------------------------------------------------------------
# Step 1 — FROM. Pull the raw rows out, and do nothing else to them.
# --------------------------------------------------------------------------
def load_rows(conn: sqlite3.Connection) -> list[dict]:
    """The FROM clause: every row, every column, no filtering yet."""
    conn.row_factory = sqlite3.Row
    cur = conn.execute("SELECT genre, rating, pages, published_year FROM books")
    return [dict(r) for r in cur.fetchall()]


# --------------------------------------------------------------------------
# Step 2 — WHERE. Keep a row only when the predicate is TRUE.
#
# This is where three-valued logic shows up in plain Python: a comparison
# against None cannot be True, so the row is dropped. SQL does exactly this,
# and the reason it feels surprising is that SQL does it silently.
# --------------------------------------------------------------------------
def where(rows: list[dict]) -> list[dict]:
    """WHERE published_year >= 2000 — rows with a NULL year cannot qualify."""
    kept = []
    for row in rows:
        year = row["published_year"]
        if year is None:
            continue  # UNKNOWN, and UNKNOWN is not TRUE
        if year >= 2000:
            kept.append(row)
    return kept


# --------------------------------------------------------------------------
# Step 3 — GROUP BY. One accumulator per distinct key.
#
# The grouping key here is `genre`, and None is a legitimate key: GROUP BY is
# the one place in SQL where all the NULLs are treated as equal to each other
# and land in a single bucket.
# --------------------------------------------------------------------------
def group_by_genre(rows: list[dict]) -> dict:
    """Fold the rows into one accumulator per genre."""
    buckets: dict = {}
    for row in rows:
        key = row["genre"]  # None is a real key, not an error
        acc = buckets.get(key)
        if acc is None:
            acc = {
                "rows": 0,  # COUNT(*)      — every row
                "rating_n": 0,  # COUNT(rating) — non-NULL ratings only
                "rating_sum": 0.0,  # SUM(rating)
                "rating_min": None,  # MIN(rating)
                "rating_max": None,  # MAX(rating)
                "pages_n": 0,
                "pages_sum": 0,
            }
            buckets[key] = acc

        acc["rows"] += 1  # COUNT(*) counts the ROW

        rating = row["rating"]
        if rating is not None:  # every other aggregate SKIPS NULL
            acc["rating_n"] += 1
            acc["rating_sum"] += rating
            if acc["rating_min"] is None or rating < acc["rating_min"]:
                acc["rating_min"] = rating
            if acc["rating_max"] is None or rating > acc["rating_max"]:
                acc["rating_max"] = rating

        pages = row["pages"]
        if pages is not None:
            acc["pages_n"] += 1
            acc["pages_sum"] += pages

    return buckets


# --------------------------------------------------------------------------
# Step 4 — HAVING, then SELECT, then ORDER BY.
# --------------------------------------------------------------------------
def finish(buckets: dict) -> list[tuple]:
    """HAVING COUNT(*) >= 3, project the columns, then sort."""
    out = []
    for key, acc in buckets.items():
        if acc["rows"] < 3:  # HAVING — filters BUCKETS, not rows
            continue

        # SELECT — the projection. AVG is SUM over the NON-NULL count, and it
        # is NULL (not 0) when nothing in the bucket had a value at all.
        avg_rating = (
            round(acc["rating_sum"] / acc["rating_n"], 6) if acc["rating_n"] else None
        )
        avg_pages = round(acc["pages_sum"] / acc["pages_n"], 6) if acc["pages_n"] else None
        label = key if key is not None else "(unclassified)"
        out.append(
            (
                label,
                acc["rows"],
                acc["rating_n"],
                avg_rating,
                acc["rating_min"],
                acc["rating_max"],
                avg_pages,
            )
        )

    # ORDER BY books DESC, genre_label ASC — last of all, on the projected rows.
    out.sort(key=lambda r: (-r[1], r[0]))
    return out


# --------------------------------------------------------------------------
# The one-line replacement. Written in the order you TYPE a SELECT, executed
# in the order the functions above are called.
# --------------------------------------------------------------------------
SQL = """
SELECT IFNULL(genre, '(unclassified)') AS genre_label,
       COUNT(*)                        AS books,
       COUNT(rating)                   AS rated,
       ROUND(AVG(rating), 6)           AS avg_rating,
       MIN(rating)                     AS min_rating,
       MAX(rating)                     AS max_rating,
       ROUND(AVG(pages), 6)            AS avg_pages
FROM books
WHERE published_year >= 2000
GROUP BY genre
HAVING COUNT(*) >= 3
ORDER BY books DESC, genre_label ASC
"""

HEADER = ("genre_label", "books", "rated", "avg_rating", "min_rating", "max_rating", "avg_pages")


def render(rows: list[tuple]) -> str:
    widths = [max(len(str(r[i])) for r in ([HEADER] + rows)) for i in range(len(HEADER))]
    lines = ["  ".join(str(h).ljust(widths[i]) for i, h in enumerate(HEADER))]
    lines.append("  ".join("-" * w for w in widths))
    for r in rows:
        lines.append("  ".join(str(v).ljust(widths[i]) for i, v in enumerate(r)))
    return "\n".join(lines)


def main() -> int:
    db_path = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else DB_PATH
    if not db_path.exists():
        print(f"error: {db_path} does not exist — run: bash examples/build_db.sh", file=sys.stderr)
        return 1

    with sqlite3.connect(db_path) as conn:
        rows = load_rows(conn)
        kept = where(rows)
        buckets = group_by_genre(kept)
        by_hand = finish(buckets)

        cur = conn.execute(SQL)
        by_sql = [tuple(r) for r in cur.fetchall()]

    print("The 20 lines of Python:")
    print(f"  FROM      -> {len(rows)} rows")
    print(f"  WHERE     -> {len(kept)} rows survive")
    print(f"  GROUP BY  -> {len(buckets)} buckets")
    print(f"  HAVING    -> {len(by_hand)} buckets survive")
    print()
    print(render(by_hand))
    print()
    print("The one SQL statement:")
    print()
    print(render(by_sql))
    print()

    if by_hand == by_sql:
        print(f"IDENTICAL: {len(by_hand)} rows match exactly.")
        return 0

    print("MISMATCH", file=sys.stderr)
    for hand, sql in zip(by_hand, by_sql):
        if hand != sql:
            print(f"  by hand: {hand}", file=sys.stderr)
            print(f"  by sql : {sql}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
examples/queries/01-filters.sql (1813 bytes)
-- 01 — WHERE: comparison and boolean operators, IN, BETWEEN
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/01-filters.sql

.print '--- 1.1 science books published this century, longest first'
SELECT title, published_year, pages
FROM books
WHERE genre = 'science' AND published_year >= 2000
ORDER BY pages DESC;

.print ''
.print '--- 1.2 two genres AND a year, with the brackets that make it mean that'
SELECT title, genre, published_year
FROM books
WHERE (genre = 'science' OR genre = 'history') AND published_year >= 2015
ORDER BY published_year;

.print ''
.print '--- 1.2b the same words without brackets: AND binds tighter than OR, so it means'
.print '         science-of-any-year OR history-since-2015 — a different question'
SELECT COUNT(*) AS with_brackets
FROM books
WHERE (genre = 'science' OR genre = 'history') AND published_year >= 2015;

SELECT COUNT(*) AS without_brackets
FROM books
WHERE genre = 'science' OR genre = 'history' AND published_year >= 2015;

.print ''
.print '--- 1.3 IN is the readable form of a chain of ORs'
SELECT title, genre
FROM books
WHERE genre IN ('poetry', 'mystery')
ORDER BY genre, title;

.print ''
.print '--- 1.4 BETWEEN is inclusive at BOTH ends'
SELECT title, published_year
FROM books
WHERE published_year BETWEEN 2015 AND 2018
ORDER BY published_year, title;

.print ''
.print '--- 1.5 the same range written out, to prove BETWEEN includes the edges'
SELECT COUNT(*) AS between_count
FROM books
WHERE published_year BETWEEN 2015 AND 2018;

SELECT COUNT(*) AS explicit_count
FROM books
WHERE published_year >= 2015 AND published_year <= 2018;

.print ''
.print '--- 1.6 NOT IN: everything except two genres (watch what happens to NULL genres)'
SELECT COUNT(*) AS not_in_count
FROM books
WHERE genre NOT IN ('poetry', 'mystery');
examples/queries/02-patterns.sql (1531 bytes)
-- 02 — LIKE and GLOB: two pattern matchers with different rules
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/02-patterns.sql
--
-- LIKE uses % (any run of characters) and _ (exactly one character), and for
-- ASCII letters it is CASE-INSENSITIVE by default in SQLite.
-- GLOB uses * and ?, plus [character classes], and is always CASE-SENSITIVE.

.print '--- 2.1 LIKE: every title containing "archive", in any case'
SELECT title
FROM books
WHERE title LIKE '%archive%'
ORDER BY title;

.print ''
.print '--- 2.2 GLOB with the same intent, lower case — case-sensitive, so it finds nothing'
SELECT COUNT(*) AS glob_lowercase_matches
FROM books
WHERE title GLOB '*archive*';

.print ''
.print '--- 2.3 GLOB spelled the way the data is spelled'
SELECT title
FROM books
WHERE title GLOB '*Archive*'
ORDER BY title;

.print ''
.print '--- 2.4 the underscore matches exactly one character — five of them, for "Quiet"'
SELECT title
FROM books
WHERE title LIKE 'The _____ Algorithm';

.print ''
.print '--- 2.4b four underscores is one too few, and the result is silence, not an error'
SELECT COUNT(*) AS four_underscores
FROM books
WHERE title LIKE 'The ____ Algorithm';

.print ''
.print '--- 2.5 GLOB character classes, which LIKE has no equivalent for'
SELECT title
FROM books
WHERE title GLOB '[AN]*'
ORDER BY title;

.print ''
.print '--- 2.6 anchored prefix search: authors whose surname starts with a letter range'
SELECT DISTINCT author
FROM books
WHERE author GLOB '* [A-M]*'
ORDER BY author;
examples/queries/03-null-traps.sql (2205 bytes)
-- 03 — NULL and three-valued logic: the traps, and the correct forms
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/03-null-traps.sql
--
-- NULL is not a value. It is the absence of one. Every comparison against it
-- returns UNKNOWN, and WHERE keeps only rows where the predicate is TRUE.
-- UNKNOWN is not TRUE, so those rows silently disappear.

.print '--- 3.1 the truth, straight from the engine: NULL = NULL is not true'
SELECT NULL = NULL           AS null_eq_null,
       NULL <> NULL          AS null_ne_null,
       NULL IS NULL          AS null_is_null,
       (NULL AND 0)          AS null_and_false,
       (NULL AND 1)          AS null_and_true,
       (NULL OR 1)           AS null_or_true,
       (NULL OR 0)           AS null_or_false,
       (NOT NULL)            AS not_null;

.print ''
.print '--- 3.2 THE TRAP: books still on loan, written the wrong way'
SELECT COUNT(*) AS wrong_still_out
FROM loans
WHERE returned_on = NULL;

.print ''
.print '--- 3.3 the same wrong idea in its other popular disguise'
SELECT COUNT(*) AS also_wrong_still_out
FROM loans
WHERE returned_on <> '';

.print ''
.print '--- 3.4 the only correct test'
SELECT COUNT(*) AS still_out
FROM loans
WHERE returned_on IS NULL;

.print ''
.print '--- 3.5 the mirror trap: "not from Pune" quietly drops members with no city'
SELECT COUNT(*) AS naive_not_pune
FROM members
WHERE city <> 'Pune';

SELECT COUNT(*) AS honest_not_pune
FROM members
WHERE city IS NULL OR city <> 'Pune';

SELECT COUNT(*) AS total_members FROM members;

.print ''
.print '--- 3.6 IS NOT DISTINCT FROM: the NULL-safe equality, spelled IS in SQLite'
SELECT COUNT(*) AS unrated_via_is
FROM books
WHERE rating IS NULL;

.print ''
.print '--- 3.7 COALESCE and IFNULL substitute a value for the absence of one'
SELECT title,
       rating                       AS raw_rating,
       COALESCE(rating, 0.0)        AS rating_as_zero,
       IFNULL(genre, 'unclassified') AS genre_filled
FROM books
WHERE rating IS NULL OR genre IS NULL
ORDER BY book_id;

.print ''
.print '--- 3.8 NULLIF is the inverse: turn a sentinel value back into NULL'
SELECT NULLIF(0, 0) AS zero_becomes_null, NULLIF(5, 0) AS five_stays_five;
examples/queries/04-sorting.sql (2238 bytes)
-- 04 — ORDER BY, DISTINCT, LIMIT and OFFSET
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/04-sorting.sql

.print '--- 4.1 two sort keys: genre ascending, then rating descending inside each genre'
SELECT genre, title, rating
FROM books
WHERE genre IS NOT NULL AND rating IS NOT NULL
ORDER BY genre ASC, rating DESC
LIMIT 12;

.print ''
.print '--- 4.2 where NULLs sort in SQLite: first when ascending, last when descending'
SELECT title, rating
FROM books
ORDER BY rating ASC
LIMIT 6;

.print ''
.print '--- 4.3 the same column descending — the NULLs move to the end'
SELECT title, rating
FROM books
ORDER BY rating DESC
LIMIT 6;

.print ''
.print '--- 4.4 forcing NULLs last regardless of direction'
SELECT title, rating
FROM books
ORDER BY rating IS NULL, rating ASC
LIMIT 6;

.print ''
.print '--- 4.5 SQLite also spells it out: NULLS LAST'
SELECT title, rating
FROM books
ORDER BY rating ASC NULLS LAST
LIMIT 6;

.print ''
.print '--- 4.6 ORDER BY can use a SELECT alias, on every engine, because it runs after SELECT'
SELECT title, pages * 2 AS reading_minutes
FROM books
WHERE pages > 400
ORDER BY reading_minutes DESC;

.print ''
.print '--- 4.6b SQLite ALSO allows the alias in WHERE, as an extension. Standard SQL does'
.print '         not, and PostgreSQL rejects it. Portable code repeats the expression.'
SELECT COUNT(*) AS accepted_by_sqlite
FROM (SELECT title, pages * 2 AS reading_minutes FROM books WHERE reading_minutes > 800);

SELECT COUNT(*) AS the_portable_spelling
FROM (SELECT title, pages * 2 AS reading_minutes FROM books WHERE pages * 2 > 800);

.print ''
.print '--- 4.7 DISTINCT removes duplicate ROWS, not duplicate values in one column'
SELECT DISTINCT author FROM books ORDER BY author;

.print ''
.print '--- 4.8 DISTINCT over two columns keeps a row per distinct PAIR'
SELECT DISTINCT author, genre FROM books ORDER BY author, genre;

.print ''
.print '--- 4.9 top-N: LIMIT without ORDER BY is a coin toss, so always pair them'
SELECT title, rating
FROM books
ORDER BY rating DESC NULLS LAST, title ASC
LIMIT 5;

.print ''
.print '--- 4.10 page two of the same list, via OFFSET'
SELECT title, rating
FROM books
ORDER BY rating DESC NULLS LAST, title ASC
LIMIT 5 OFFSET 5;
examples/queries/05-aggregates.sql (2143 bytes)
-- 05 — the five aggregates, and what NULL does to each of them
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/05-aggregates.sql
--
-- The rule that explains everything below: every aggregate except COUNT(*)
-- IGNORES NULL inputs. It does not treat them as zero. It does not treat them
-- as anything. They are removed before the arithmetic starts.

.print '--- 5.1 COUNT(*) counts ROWS; COUNT(column) counts NON-NULL VALUES in that column'
SELECT COUNT(*)               AS rows_total,
       COUNT(rating)          AS rows_with_a_rating,
       COUNT(genre)           AS rows_with_a_genre,
       COUNT(*) - COUNT(rating) AS unrated_books
FROM books;

.print ''
.print '--- 5.2 COUNT(DISTINCT column) counts distinct non-NULL values'
SELECT COUNT(author)          AS author_cells,
       COUNT(DISTINCT author) AS distinct_authors,
       COUNT(DISTINCT genre)  AS distinct_genres_excluding_null
FROM books;

.print ''
.print '--- 5.3 AVG over a column with NULLs divides by the NON-NULL count'
SELECT ROUND(AVG(rating), 4)                  AS avg_ignoring_nulls,
       ROUND(SUM(rating) / COUNT(rating), 4)  AS same_thing_by_hand,
       ROUND(SUM(rating) / COUNT(*), 4)       AS avg_if_nulls_counted_as_zero
FROM books;

.print ''
.print '--- 5.4 and the version people mean when they "fix" the NULLs — a different number'
SELECT ROUND(AVG(COALESCE(rating, 0.0)), 4) AS avg_with_nulls_as_zero
FROM books;

.print ''
.print '--- 5.5 SUM of an all-NULL set is NULL, not 0. TOTAL is the same sum returning 0.0'
SELECT SUM(rating)   AS sum_of_no_rows,
       TOTAL(rating) AS total_of_no_rows,
       COUNT(*)      AS matching_rows
FROM books
WHERE genre = 'no-such-genre';

.print ''
.print '--- 5.6 MIN and MAX also skip NULLs entirely'
SELECT MIN(rating) AS lowest_rating,
       MAX(rating) AS highest_rating,
       MIN(published_year) AS earliest_year,
       MAX(published_year) AS latest_year
FROM books;

.print ''
.print '--- 5.7 an aggregate over zero rows still returns exactly one row'
SELECT COUNT(*) AS n, AVG(rating) AS avg_rating, MAX(pages) AS longest
FROM books
WHERE published_year = 1066;
examples/queries/06-group-by.sql (2435 bytes)
-- 06 — GROUP BY: one output row per distinct grouping key
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/06-group-by.sql
--
-- GROUP BY collapses the rows that survived WHERE into buckets. After it runs,
-- the only things you may ask for are the grouping key itself and aggregates
-- over the bucket — because there is no longer one row to take a value from.

.print '--- 6.1 how many books per genre, commonest first'
SELECT genre, COUNT(*) AS n
FROM books
GROUP BY genre
ORDER BY n DESC, genre;

.print ''
.print '--- 6.2 GROUP BY puts all the NULLs in ONE bucket — the one place NULLs are treated as equal'
SELECT IFNULL(genre, '(unclassified)') AS genre_label, COUNT(*) AS n
FROM books
GROUP BY genre
ORDER BY n DESC, genre_label;

.print ''
.print '--- 6.3 two aggregates per bucket, and the COUNT gap that reveals the NULLs'
SELECT IFNULL(genre, '(unclassified)') AS genre_label,
       COUNT(*)                        AS books,
       COUNT(rating)                   AS rated,
       ROUND(AVG(rating), 3)           AS avg_rating
FROM books
GROUP BY genre
ORDER BY books DESC, genre_label;

.print ''
.print '--- 6.4 grouping by an EXPRESSION rather than a column: books per decade'
SELECT (published_year / 10) * 10 AS decade, COUNT(*) AS n
FROM books
WHERE published_year IS NOT NULL
GROUP BY decade
ORDER BY decade;

.print ''
.print '--- 6.5 grouping by two keys gives one row per combination that actually occurs'
SELECT author, IFNULL(genre, '(unclassified)') AS genre_label, COUNT(*) AS n
FROM books
GROUP BY author, genre
ORDER BY author, genre_label;

.print ''
.print '--- 6.6 the loans table: who borrows most'
SELECT member_id, COUNT(*) AS loans_taken
FROM loans
GROUP BY member_id
ORDER BY loans_taken DESC, member_id
LIMIT 5;

.print ''
.print '--- 6.7 an aggregate over a CASE is how you count a subset inside a bucket'
SELECT member_id,
       COUNT(*)                                      AS loans_taken,
       SUM(CASE WHEN returned_on IS NULL THEN 1 ELSE 0 END) AS still_out,
       COUNT(returned_on)                            AS returned
FROM loans
GROUP BY member_id
ORDER BY still_out DESC, member_id;

.print ''
.print '--- 6.8 WHERE runs BEFORE GROUP BY, so this counts only the loans of 2026 Q1'
SELECT member_id, COUNT(*) AS q1_loans
FROM loans
WHERE borrowed_on BETWEEN '2026-01-01' AND '2026-03-31'
GROUP BY member_id
ORDER BY q1_loans DESC, member_id
LIMIT 5;
examples/queries/07-having.sql (2303 bytes)
-- 07 — HAVING: the filter that WHERE cannot express
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/07-having.sql
--
-- WHERE filters ROWS before grouping. HAVING filters GROUPS after grouping.
-- "Authors with more than three books" is not a fact about any single row, so
-- WHERE cannot see it. There is no row that knows how many books its author
-- wrote — only the bucket knows.

.print '--- 7.1 authors with more than three books in the catalogue'
SELECT author, COUNT(*) AS titles
FROM books
GROUP BY author
HAVING COUNT(*) > 3
ORDER BY titles DESC, author;

.print ''
.print '--- 7.2 the attempt WHERE cannot make (kept as a comment, because it is an error)'
--  SELECT author, COUNT(*) AS titles FROM books WHERE COUNT(*) > 3 GROUP BY author;
--  -> Error: in prepare, misuse of aggregate: COUNT()
.print '     see the comment in this file, and section 3 of tests/run_tests.sh'

.print ''
.print '--- 7.3 WHERE and HAVING in the same query, each doing its own job'
SELECT genre, COUNT(*) AS n, ROUND(AVG(rating), 3) AS avg_rating
FROM books
WHERE published_year >= 2000        -- throws away rows
GROUP BY genre
HAVING COUNT(*) >= 2                -- throws away buckets
ORDER BY n DESC, genre;

.print ''
.print '--- 7.4 HAVING on an aggregate that is not in the SELECT list at all'
SELECT genre
FROM books
GROUP BY genre
HAVING AVG(pages) > 350
ORDER BY genre;

.print ''
.print '--- 7.5 books borrowed more than twice — the classic popularity query'
SELECT book_id, COUNT(*) AS times_borrowed
FROM loans
GROUP BY book_id
HAVING COUNT(*) > 2
ORDER BY times_borrowed DESC, book_id;

.print ''
.print '--- 7.6 HAVING on an expression built from TWO aggregates: two or more books still out'
SELECT member_id,
       COUNT(*)                     AS loans_taken,
       COUNT(*) - COUNT(returned_on) AS still_out
FROM loans
GROUP BY member_id
HAVING COUNT(*) - COUNT(returned_on) >= 2
ORDER BY still_out DESC, member_id;

.print ''
.print '--- 7.7 SQLite lets a bare column ride along in an aggregate query; most engines do not'
SELECT genre, title, COUNT(*) AS n
FROM books
GROUP BY genre
ORDER BY genre;
.print '     the title above is ONE arbitrary row from each bucket, not a summary.'
.print '     PostgreSQL rejects this query outright. Do not rely on it.'
examples/queries/08-case-and-functions.sql (2792 bytes)
-- 08 — computed columns, aliases, scalar functions and CASE
--
-- Run:  sqlite3 -header -column examples/library.db < examples/queries/08-case-and-functions.sql
--
-- A scalar function takes one row's values and returns one value. An aggregate
-- takes many rows and returns one value. That single distinction decides which
-- clause each one is legal in.

.print '--- 8.1 a computed column with an alias'
SELECT title,
       pages,
       ROUND(pages / 250.0, 2) AS evenings_needed
FROM books
ORDER BY evenings_needed DESC
LIMIT 5;

.print ''
.print '--- 8.2 the string functions worth knowing'
SELECT UPPER(SUBSTR(author, 1, 1)) AS initial,
       LENGTH(title)               AS title_length,
       LOWER(genre)                AS genre_lower,
       REPLACE(title, 'The ', '')  AS title_without_the,
       TRIM('   padded   ')        AS trimmed
FROM books
WHERE book_id IN (1, 15)
ORDER BY book_id;

.print ''
.print '--- 8.3 concatenation is || in SQL, not + and not a function'
SELECT title || ' (' || author || ')' AS citation
FROM books
ORDER BY book_id
LIMIT 3;

.print ''
.print '--- 8.4 numeric and null-handling scalars'
SELECT ABS(-7)                    AS abs_value,
       ROUND(3.14159, 3)          AS rounded,
       CAST('42' AS INTEGER) + 1  AS cast_then_add,
       TYPEOF(rating)             AS rating_type,
       TYPEOF(NULL)               AS null_type
FROM books
WHERE book_id = 4;

.print ''
.print '--- 8.5 date functions: SQLite stores dates as text and reads them with these'
SELECT borrowed_on,
       STRFTIME('%Y-%m', borrowed_on)               AS month_bucket,
       JULIANDAY(returned_on) - JULIANDAY(borrowed_on) AS days_held
FROM loans
WHERE returned_on IS NOT NULL
ORDER BY loan_id
LIMIT 5;

.print ''
.print '--- 8.6 CASE turns a value into a label — the SQL equivalent of if/elif/else'
SELECT title,
       rating,
       CASE
         WHEN rating IS NULL  THEN 'unrated'
         WHEN rating >= 4.5   THEN 'excellent'
         WHEN rating >= 4.0   THEN 'good'
         WHEN rating >= 3.5   THEN 'fair'
         ELSE                      'poor'
       END AS band
FROM books
ORDER BY book_id
LIMIT 10;

.print ''
.print '--- 8.7 GROUP BY over a CASE expression: a histogram of rating bands'
SELECT CASE
         WHEN rating IS NULL  THEN 'unrated'
         WHEN rating >= 4.5   THEN 'excellent'
         WHEN rating >= 4.0   THEN 'good'
         WHEN rating >= 3.5   THEN 'fair'
         ELSE                      'poor'
       END AS band,
       COUNT(*) AS n
FROM books
GROUP BY band
ORDER BY n DESC, band;

.print ''
.print '--- 8.8 the WHEN order matters: put the NULL branch first or it never fires'
SELECT COUNT(*) AS mislabelled_as_poor
FROM books
WHERE rating IS NULL
  AND (CASE WHEN rating >= 4.0 THEN 'good' ELSE 'poor' END) = 'poor';
examples/seed.sql (7410 bytes)
-- Day 086 lab — the seed for library.db
--
-- Everything in this lab runs against this one database. It is deliberately
-- small enough to check by hand and deliberately full of NULLs, because NULL
-- is where the day's real lessons live.
--
-- Build it with:
--   bash examples/build_db.sh
-- or directly:
--   rm -f examples/library.db && sqlite3 examples/library.db < examples/seed.sql
--
-- Three deliberate holes are drilled into the data, and every one of them is
-- an exercise later:
--   * books.rating is NULL for books nobody has rated yet
--   * books.genre is NULL for books that were never classified
--   * members.city is NULL for members who did not give one
--   * loans.returned_on is NULL for books that are still out
--
-- The last one carries the most weight: "still on loan" is not a value, it is
-- the absence of one, and that is why `returned_on <> ''` finds nothing.

PRAGMA foreign_keys = ON;

DROP TABLE IF EXISTS loans;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS books;

CREATE TABLE books (
  book_id        INTEGER PRIMARY KEY,
  title          TEXT    NOT NULL,
  author         TEXT    NOT NULL,
  genre          TEXT,             -- NULL = never classified
  published_year INTEGER,          -- NULL = publication date unknown
  pages          INTEGER,
  rating         REAL,             -- NULL = not yet rated; 1.0 to 5.0 otherwise
  copies         INTEGER NOT NULL
);

CREATE TABLE members (
  member_id  INTEGER PRIMARY KEY,
  full_name  TEXT NOT NULL,
  joined_on  TEXT NOT NULL,        -- ISO-8601 date, stored as TEXT
  city       TEXT,                 -- NULL = not supplied
  email      TEXT
);

CREATE TABLE loans (
  loan_id     INTEGER PRIMARY KEY,
  book_id     INTEGER NOT NULL REFERENCES books(book_id),
  member_id   INTEGER NOT NULL REFERENCES members(member_id),
  borrowed_on TEXT NOT NULL,
  returned_on TEXT               -- NULL = still on loan
);

INSERT INTO books (book_id, title, author, genre, published_year, pages, rating, copies) VALUES
  (1,  'The Silent Archive',        'Priya Raman',      'mystery',    2018, 312, 4.5,  3),
  (2,  'Grammar of Machines',       'Ada Fenwick',      'science',    2011, 480, 4.8,  2),
  (3,  'Salt and Longitude',        'Tomas Berg',       'history',    1997, 528, 3.9,  1),
  (4,  'The Quiet Algorithm',       'Ada Fenwick',      'science',    2021, 264, NULL, 4),
  (5,  'Nine Rivers',               'Kofi Mensah',      'fiction',    2005, 398, 4.1,  2),
  (6,  'A Map of Small Errors',     'Priya Raman',      'mystery',    2022, 288, 4.4,  5),
  (7,  'Ledger of Tides',           'Marta Iglesias',   'fiction',    1988, 356, 3.2,  1),
  (8,  'The Glasshouse Problem',    'Ada Fenwick',      'science',    2016, 344, 4.6,  3),
  (9,  'Winter Counting',           'Hana Sato',        NULL,         2019, 210, 4.0,  2),
  (10, 'The Lost Cartographers',    'Tomas Berg',       'history',    2003, 612, 4.2,  1),
  (11, 'Field Notes on Rain',       'Hana Sato',        'poetry',     2014, 96,  NULL, 6),
  (12, 'Eleven Ways to Fail',       'Daniel Okoro',     'science',    2020, 302, 3.7,  2),
  (13, 'The Anchor Room',           'Marta Iglesias',   'mystery',    2012, 274, 4.3,  3),
  (14, 'Continental Drift Blues',   'Kofi Mensah',      'fiction',    2017, 421, 3.5,  1),
  (15, 'Small Gods of Arithmetic',  'Ada Fenwick',      NULL,         NULL, 198, 4.9,  1),
  (16, 'The Paper Observatory',     'Hana Sato',        'science',    2009, 366, 4.4,  2),
  (17, 'Coasts of Elsewhere',       'Tomas Berg',       'history',    2023, 455, NULL, 4),
  (18, 'A Winter Grammar',          'Priya Raman',      'poetry',     1999, 128, 3.8,  1),
  (19, 'The Long Instrument',       'Daniel Okoro',     'fiction',    2015, 512, 4.7,  2),
  (20, 'Notes Toward a Machine',    'Ada Fenwick',      'science',    2024, 240, NULL, 3),
  (21, 'The Weather in Numbers',    'Marta Iglesias',   'science',    2013, 288, 4.0,  2),
  (22, 'Wintering Grounds',         'Kofi Mensah',      NULL,         2007, 334, 3.4,  1),
  (23, 'The Second Archive',        'Priya Raman',      'mystery',    2025, 296, 4.6,  4),
  (24, 'Poems for a Dry Season',    'Hana Sato',        'poetry',     2021, 112, 4.2,  2);

INSERT INTO members (member_id, full_name, joined_on, city, email) VALUES
  (1,  'Anita Desai',      '2021-03-14', 'Pune',      'anita.desai@library.invalid'),
  (2,  'Ben Oyelaran',     '2022-07-02', 'Lagos',     'ben.oyelaran@library.invalid'),
  (3,  'Chen Wei',         '2020-11-30', 'Singapore', NULL),
  (4,  'Dana Kowalski',    '2023-01-19', NULL,        'dana.k@library.invalid'),
  (5,  'Elif Demir',       '2019-05-08', 'Izmir',     'elif.demir@library.invalid'),
  (6,  'Farid Nazari',     '2024-02-11', 'Pune',      NULL),
  (7,  'Grace Mwangi',     '2022-09-23', 'Nairobi',   'grace.mwangi@library.invalid'),
  (8,  'Hugo Almeida',     '2018-08-16', NULL,        NULL),
  (9,  'Ines Moreau',      '2023-10-05', 'Lyon',      'ines.moreau@library.invalid'),
  (10, 'Jonas Lindqvist',  '2025-04-27', 'Uppsala',   'jonas.l@library.invalid'),
  (11, 'Keiko Tanaka',     '2021-12-01', 'Sendai',    'keiko.tanaka@library.invalid'),
  (12, 'Luis Ferreira',    '2024-06-30', 'Porto',     NULL);

INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, returned_on) VALUES
  (1,  1,  1,  '2026-01-05', '2026-01-19'),
  (2,  2,  1,  '2026-01-05', '2026-02-02'),
  (3,  6,  2,  '2026-01-11', '2026-01-25'),
  (4,  8,  3,  '2026-01-14', NULL),
  (5,  5,  4,  '2026-01-18', '2026-02-01'),
  (6,  13, 5,  '2026-01-20', '2026-01-27'),
  (7,  1,  6,  '2026-01-22', NULL),
  (8,  19, 7,  '2026-01-26', '2026-02-16'),
  (9,  4,  2,  '2026-02-01', '2026-02-15'),
  (10, 11, 8,  '2026-02-03', NULL),
  (11, 16, 9,  '2026-02-04', '2026-02-18'),
  (12, 2,  10, '2026-02-07', '2026-02-21'),
  (13, 23, 1,  '2026-02-09', NULL),
  (14, 6,  11, '2026-02-10', '2026-02-24'),
  (15, 3,  12, '2026-02-12', '2026-03-05'),
  (16, 8,  4,  '2026-02-14', '2026-02-28'),
  (17, 21, 5,  '2026-02-15', NULL),
  (18, 6,  7,  '2026-02-17', '2026-03-03'),
  (19, 10, 3,  '2026-02-19', '2026-03-12'),
  (20, 1,  9,  '2026-02-21', '2026-03-07'),
  (21, 24, 2,  '2026-02-23', NULL),
  (22, 19, 6,  '2026-02-25', '2026-03-11'),
  (23, 13, 10, '2026-03-01', '2026-03-15'),
  (24, 6,  12, '2026-03-02', NULL),
  (25, 20, 1,  '2026-03-04', '2026-03-18'),
  (26, 9,  8,  '2026-03-06', '2026-03-20'),
  (27, 2,  11, '2026-03-08', NULL),
  (28, 17, 5,  '2026-03-09', '2026-03-23'),
  (29, 1,  4,  '2026-03-11', '2026-03-25'),
  (30, 12, 7,  '2026-03-13', NULL),
  (31, 6,  9,  '2026-03-15', '2026-03-29'),
  (32, 8,  1,  '2026-03-17', '2026-03-31'),
  (33, 23, 2,  '2026-03-19', NULL),
  (34, 5,  3,  '2026-03-21', '2026-04-04'),
  (35, 19, 12, '2026-03-23', '2026-04-06'),
  (36, 16, 6,  '2026-03-25', NULL),
  (37, 1,  11, '2026-03-27', '2026-04-10'),
  (38, 13, 8,  '2026-03-29', '2026-04-12'),
  (39, 6,  10, '2026-04-01', NULL),
  (40, 21, 4,  '2026-04-03', '2026-04-17'),
  (41, 2,  5,  '2026-04-05', NULL),
  (42, 23, 9,  '2026-04-07', '2026-04-21'),
  (43, 8,  7,  '2026-04-09', NULL),
  (44, 6,  1,  '2026-04-11', '2026-04-25'),
  (45, 15, 3,  '2026-04-13', NULL);

-- A last sanity line so a successful build is visible rather than silent.
SELECT 'seeded: ' || (SELECT COUNT(*) FROM books) || ' books, '
                  || (SELECT COUNT(*) FROM members) || ' members, '
                  || (SELECT COUNT(*) FROM loans) || ' loans';
metadata.yml (1558 bytes)
lesson_id: D086
day: 86
kind: guided-build
languages: [sql, python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-086-select-filtering-sorting-and-aggregating
  - sqlite3 --version
  - python3 --version
  - bash examples/build_db.sh
run_commands:
  - sqlite3 -header -column examples/library.db < examples/queries/01-filters.sql
  - sqlite3 -header -column examples/library.db < examples/queries/02-patterns.sql
  - sqlite3 -header -column examples/library.db < examples/queries/03-null-traps.sql
  - sqlite3 -header -column examples/library.db < examples/queries/04-sorting.sql
  - sqlite3 -header -column examples/library.db < examples/queries/05-aggregates.sql
  - sqlite3 -header -column examples/library.db < examples/queries/06-group-by.sql
  - sqlite3 -header -column examples/library.db < examples/queries/07-having.sql
  - sqlite3 -header -column examples/library.db < examples/queries/08-case-and-functions.sql
  - python3 examples/groupby_from_scratch.py
  - sqlite3 examples/library.db < starter/exercises.sql
  - bash starter/check.sh
  - sqlite3 examples/library.db < examples/exercise-answers.sql
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -f examples/library.db
  - 'git checkout -- starter/exercises.sql  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), sqlite3 3.51.0, Python 3.14.0, bash 3.2.57 — bash tests/run_tests.sh -> 124 checks, 0 failure(s), exit 0'
requirements/README.md (2482 bytes)
# Dependencies

There are none to install. That is not laziness; it is the point of the week.

| Tool | Version verified here | Why this lab needs it | Licence |
| --- | --- | --- | --- |
| `sqlite3` (the shell) | 3.51.0 | Runs every query in `examples/queries/`, the seed, and the whole test harness | Public domain |
| `python3` | 3.14.0 | Runs `examples/groupby_from_scratch.py`, which builds GROUP BY out of a dictionary of accumulators and then checks SQL agrees | Python Software Foundation License |
| `bash` | 3.2.57 | Runs `tests/run_tests.sh`, `examples/build_db.sh` and `starter/check.sh` | GPL-3.0 |
| `sqlite3` (the Python module) | Standard library | The comparison script's connection to the same database file | Python Software Foundation License |

All four are free, and three of the four are already on any macOS or Linux
machine you would be reading this on.

## Installing the one that might be missing

macOS ships the `sqlite3` shell. On Debian and Ubuntu the shell is packaged
separately from the library, so it is possible to have a perfectly working
`import sqlite3` in Python and no `sqlite3` command at all:

```bash
sudo apt install sqlite3      # Debian, Ubuntu
sudo dnf install sqlite       # Fedora
```

`tests/run_tests.sh` checks for the command before it does anything else and
stops with that instruction rather than failing halfway through a run.

## What is deliberately absent

**No ORM.** Not SQLAlchemy, not Django's, not Peewee. Every one of them is a
good tool and you will meet them later. Today the whole subject is the shape of
a SELECT and the order its clauses run in, and an ORM's job is to hide exactly
that. Learning the abstraction before the thing it abstracts is how people end
up unable to explain why their query is slow.

**No pandas.** The Alternatives section of the lesson covers it honestly as the
dataframe answer to the same questions, including where it wins and where it
loses. It is not installed here, and the lesson says so rather than showing
output that was never produced.

**No database server.** SQLite is a library and a file. There is no daemon to
start, no port to open, no user to create, and nothing to uninstall afterwards
except one file you can delete with `rm`.

**No seed data from the internet.** `examples/seed.sql` is 45 loans, 24 books
and 12 members written by hand, chosen so that every number in the tests can be
checked by reading the file. The whole lab runs with the network switched off.
requirements/requirements.txt (460 bytes)
# This lab installs nothing. There is no pip line to run.
#
# It needs the sqlite3 shell, which macOS ships and which Debian and Ubuntu
# package as `sqlite3`, plus python3 for the from-scratch comparison. The
# `sqlite3` module python3 uses is part of the standard library.
#
# Verified on the authoring machine, 2026-08-16:
#   sqlite3  3.51.0
#   python3  3.14.0
#   bash     3.2.57
#
# See README.md in this directory for why the list is empty on purpose.
starter/check.sh (2045 bytes)
#!/usr/bin/env bash
# Score starter/exercises.sql against the required answers.
#
# Run from the lab directory:
#   bash starter/check.sh
#
# Exits 0 when all twelve are right, non-zero otherwise. The required answers
# are printed in the exercise comments too — nothing here is hidden. What is
# hidden is the QUERY, which is the part you are meant to write.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
db="${lab_dir}/examples/library.db"

if [ ! -f "${db}" ]; then
  echo "Building the database first (it was missing)."
  bash "${lab_dir}/examples/build_db.sh" >/dev/null || exit 1
fi

# label -> required answer. Same twelve values as the exercise comments.
expected_labels="ex01 ex02 ex03 ex04 ex05 ex06 ex07 ex08 ex09 ex10 ex11 ex12"
expected_for() {
  case "$1" in
    ex01) echo "15" ;;
    ex02) echo "10" ;;
    ex03) echo "2" ;;
    ex04) echo "4" ;;
    ex05) echo "4.16" ;;
    ex06) echo "4" ;;
    ex07) echo "Ledger of Tides" ;;
    ex08) echo "6" ;;
    ex09) echo "Ada Fenwick" ;;
    ex10) echo "3" ;;
    ex11) echo "28.0" ;;
    ex12) echo "4" ;;
    *)    echo "" ;;
  esac
}

actual="$(sqlite3 "${db}" < "${lab_dir}/starter/exercises.sql" 2>&1)" || {
  echo "Your exercises.sql did not run. SQLite said:"
  echo "${actual}"
  exit 1
}

right=0
wrong=0
echo "Exercise   your answer                required"
echo "---------  -------------------------  -------------------------"
for label in ${expected_labels}; do
  want="$(expected_for "${label}")"
  got="$(printf '%s\n' "${actual}" | grep "^${label}|" | head -1 | cut -d'|' -f2-)"
  if [ -z "${got}" ]; then
    got="(no ${label} line)"
  fi
  if [ "${got}" = "${want}" ]; then
    printf '%-9s  %-25s  %-25s  ok\n' "${label}" "${got}" "${want}"
    right=$((right + 1))
  else
    printf '%-9s  %-25s  %-25s  WRONG\n' "${label}" "${got}" "${want}"
    wrong=$((wrong + 1))
  fi
done

echo
echo "${right} correct, ${wrong} still wrong."
[ "${wrong}" -eq 0 ] || exit 1
echo "All twelve. Every one of them ran and lied to you before you fixed it."
starter/exercises.sql (6450 bytes)
-- Day 086 lab — YOUR WORK. Twelve numbered exercises.
--
-- Run them at any time, from the lab directory:
--   sqlite3 examples/library.db < starter/exercises.sql
-- Score them:
--   bash starter/check.sh
--
-- Every exercise below RUNS right now. None of them is blank, and none of them
-- is an error. Each one is a query somebody wrote in a hurry, which returns a
-- confident, well-formatted, WRONG answer. Your job is to make each one right.
--
-- That is the shape of the whole day. SQL almost never tells you that you asked
-- the wrong question; it just answers the one you actually asked.
--
-- Each statement prints one line: exNN|value. `bash starter/check.sh` compares
-- those values to the required answers and tells you which are still wrong.
-- Do NOT change the exNN label or the number of statements.

.mode list
.headers off

-- ---------------------------------------------------------------------------
-- Exercise 1 — How many loans are still outstanding?
-- A book that is still out has no returned_on date at all.
-- Required answer: 15
-- Hint: NULL is never equal to anything, including NULL.
-- ---------------------------------------------------------------------------
SELECT 'ex01|' || (
  SELECT COUNT(*) FROM loans WHERE returned_on = NULL
);

-- ---------------------------------------------------------------------------
-- Exercise 2 — How many members are NOT from Pune?
-- A member who never told us their city is certainly not from Pune.
-- Required answer: 10
-- Hint: `city <> 'Pune'` is UNKNOWN when city is NULL, and WHERE keeps only TRUE.
-- ---------------------------------------------------------------------------
SELECT 'ex02|' || (
  SELECT COUNT(*) FROM members WHERE city <> 'Pune'
);

-- ---------------------------------------------------------------------------
-- Exercise 3 — How many titles contain the word "archive", in any case?
-- Required answer: 2
-- Hint: GLOB is case-sensitive. One of SQLite's two pattern matchers is not.
-- ---------------------------------------------------------------------------
SELECT 'ex03|' || (
  SELECT COUNT(*) FROM books WHERE title GLOB '*archive*'
);

-- ---------------------------------------------------------------------------
-- Exercise 4 — How many books were published in 2015, 2016, 2017 or 2018?
-- Required answer: 4
-- Hint: BETWEEN includes both endpoints. Strict inequalities do not.
-- ---------------------------------------------------------------------------
SELECT 'ex04|' || (
  SELECT COUNT(*) FROM books WHERE published_year > 2015 AND published_year < 2018
);

-- ---------------------------------------------------------------------------
-- Exercise 5 — What is the average rating of the books that HAVE a rating,
-- rounded to two decimal places?
-- Required answer: 4.16
-- Hint: an unrated book is not a book rated zero. Do not invent data.
-- ---------------------------------------------------------------------------
SELECT 'ex05|' || (
  SELECT ROUND(AVG(COALESCE(rating, 0.0)), 2) FROM books
);

-- ---------------------------------------------------------------------------
-- Exercise 6 — How many books have never been rated?
-- Required answer: 4
-- Hint: COUNT(*) and COUNT(column) count different things. The gap is the answer.
-- ---------------------------------------------------------------------------
SELECT 'ex06|' || (
  SELECT COUNT(rating) FROM books
);

-- ---------------------------------------------------------------------------
-- Exercise 7 — What is the title of the LOWEST-rated book that has a rating?
-- Required answer: Ledger of Tides
-- Hint: ascending order puts the NULLs first in SQLite, and a NULL is not a
-- low rating — it is no rating.
-- ---------------------------------------------------------------------------
SELECT 'ex07|' || (
  SELECT title FROM books ORDER BY rating ASC LIMIT 1
);

-- ---------------------------------------------------------------------------
-- Exercise 8 — How many genre buckets does the catalogue have, counting the
-- unclassified books as one bucket of their own?
-- Required answer: 6
-- Hint: COUNT(DISTINCT genre) skips NULL. GROUP BY does not.
-- ---------------------------------------------------------------------------
SELECT 'ex08|' || (
  SELECT COUNT(DISTINCT genre) FROM books
);

-- ---------------------------------------------------------------------------
-- Exercise 9 — Which author has the most titles in the catalogue?
-- Required answer: Ada Fenwick
-- Hint: ORDER BY defaults to ascending, which gives you the answer to the
-- opposite question.
-- ---------------------------------------------------------------------------
SELECT 'ex09|' || (
  SELECT author FROM books GROUP BY author ORDER BY COUNT(*) LIMIT 1
);

-- ---------------------------------------------------------------------------
-- Exercise 10 — How many authors have MORE THAN THREE titles?
-- Required answer: 3
-- Hint: "how many titles this author has" is a fact about a group, not about a
-- row, so WHERE cannot see it. There is exactly one clause that can.
-- ---------------------------------------------------------------------------
SELECT 'ex10|' || (
  SELECT COUNT(DISTINCT author) FROM books
);

-- ---------------------------------------------------------------------------
-- Exercise 11 — For how many days was loan 2 held?
-- Required answer: 28.0
-- Hint: SQLite has no date type. Those two columns are TEXT, and subtracting
-- one piece of text from another gives you arithmetic on whatever number the
-- engine can squeeze out of the front of each string.
-- ---------------------------------------------------------------------------
SELECT 'ex11|' || (
  SELECT returned_on - borrowed_on FROM loans WHERE loan_id = 2
);

-- ---------------------------------------------------------------------------
-- Exercise 12 — How many books land in the 'unrated' band of this histogram?
-- Required answer: 4
-- Hint: CASE evaluates its WHEN branches in order and stops at the first TRUE.
-- A comparison against NULL is never TRUE, so it falls through to the ELSE.
-- ---------------------------------------------------------------------------
SELECT 'ex12|' || (
  SELECT COUNT(*) FROM (
    SELECT CASE
             WHEN rating >= 4.5 THEN 'excellent'
             WHEN rating >= 4.0 THEN 'good'
             ELSE                    'poor'
           END AS band
    FROM books
  )
  WHERE band = 'unrated'
);
tests/run_tests.sh (20031 bytes)
#!/usr/bin/env bash
# Tests for the Day 086 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Every check here compares an ACTUAL RESULT VALUE against a number or string
# that was worked out from the seed data by hand. A test that only proves a
# query parsed would pass on every one of the twelve broken queries in
# starter/exercises.sql, which is exactly the failure mode this day is about.
#
# The suite builds its own throwaway database under a mktemp -d directory and
# removes it in a trap, so it never touches examples/library.db and never
# depends on what you did to it. Nothing here reaches the network, nothing
# needs sudo, and nothing survives the run.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
checks=0
failures=0
work_root=""

cleanup() {
  [ -n "${work_root}" ] && [ -d "${work_root}" ] && rm -rf "${work_root}"
}
trap cleanup EXIT INT TERM

pass() { checks=$((checks + 1)); echo "  ok: $1"; }
fail() { checks=$((checks + 1)); failures=$((failures + 1)); echo "  FAIL: $1"; }

# check <label> <expected> <actual>
check() {
  if [ "$2" = "$3" ]; then
    pass "$1 = $3"
  else
    fail "$1: expected [$2] but got [$3]"
  fi
}

# check_ne <label> <must-not-equal> <actual>
check_ne() {
  if [ "$2" != "$3" ]; then
    pass "$1 (is not $2)"
  else
    fail "$1: value must NOT be [$2] but it is"
  fi
}

if ! command -v sqlite3 >/dev/null 2>&1; then
  echo "FAIL: the sqlite3 shell is not on PATH." >&2
  echo "  macOS ships it; on Debian or Ubuntu: sudo apt install sqlite3" >&2
  exit 1
fi
python_bin="${PYTHON:-python3}"
if ! command -v "${python_bin}" >/dev/null 2>&1; then
  echo "FAIL: python3 is not on PATH (needed for the from-scratch comparison)." >&2
  exit 1
fi

work_root="$(mktemp -d)"
db="${work_root}/library.db"

# q <sql> -> the single value the query returns, with no header and no padding
q() { sqlite3 "${db}" "$1"; }

echo "Day 086 — Ask the Database Questions"
echo "sqlite3 $(sqlite3 --version | cut -d' ' -f1), $(${python_bin} --version)"
echo "throwaway database: ${db}"
echo

echo "1. The seed builds, and builds the same thing every time"
bash "${lab_dir}/examples/build_db.sh" "${db}" >/dev/null 2>&1
if [ -f "${db}" ]; then pass "examples/build_db.sh created the database"
else fail "examples/build_db.sh did not create a database"; fi
check "books"   "24" "$(q 'SELECT COUNT(*) FROM books;')"
check "members" "12" "$(q 'SELECT COUNT(*) FROM members;')"
check "loans"   "45" "$(q 'SELECT COUNT(*) FROM loans;')"
check "the deliberate holes: unrated books"      "4"  "$(q 'SELECT COUNT(*)-COUNT(rating) FROM books;')"
check "the deliberate holes: unclassified books" "3"  "$(q 'SELECT COUNT(*)-COUNT(genre) FROM books;')"
check "the deliberate holes: members with no city" "2" "$(q 'SELECT COUNT(*)-COUNT(city) FROM members;')"
check "the deliberate holes: loans still out"    "15" "$(q 'SELECT COUNT(*)-COUNT(returned_on) FROM loans;')"
# Rebuilding must be idempotent — the same rows, not a doubled table.
bash "${lab_dir}/examples/build_db.sh" "${db}" >/dev/null 2>&1
check "rebuilding gives the same 24 books, not 48" "24" "$(q 'SELECT COUNT(*) FROM books;')"
echo

echo "2. WHERE: comparisons, boolean operators, IN, BETWEEN"
check "science books from 2000 onwards" "7" \
  "$(q "SELECT COUNT(*) FROM books WHERE genre='science' AND published_year>=2000;")"
check "BETWEEN 2015 AND 2018 includes both endpoints" "4" \
  "$(q 'SELECT COUNT(*) FROM books WHERE published_year BETWEEN 2015 AND 2018;')"
check "the same range spelled out with >= and <=" "4" \
  "$(q 'SELECT COUNT(*) FROM books WHERE published_year>=2015 AND published_year<=2018;')"
check "IN over two genres" "7" \
  "$(q "SELECT COUNT(*) FROM books WHERE genre IN ('poetry','mystery');")"
check "brackets change the meaning: (A OR B) AND C" "5" \
  "$(q "SELECT COUNT(*) FROM books WHERE (genre='science' OR genre='history') AND published_year>=2015;")"
check "AND binds tighter than OR: A OR (B AND C)" "8" \
  "$(q "SELECT COUNT(*) FROM books WHERE genre='science' OR genre='history' AND published_year>=2015;")"
# NOT IN drops the NULL-genre rows: 24 - 7 matching - 3 unclassified = 14.
check "NOT IN silently excludes the NULL genres too" "14" \
  "$(q "SELECT COUNT(*) FROM books WHERE genre NOT IN ('poetry','mystery');")"
echo

echo "3. LIKE and GLOB really do differ on case"
check "LIKE %archive% folds case"        "2" "$(q "SELECT COUNT(*) FROM books WHERE title LIKE '%archive%';")"
check "GLOB *archive* does not"          "0" "$(q "SELECT COUNT(*) FROM books WHERE title GLOB '*archive*';")"
check "GLOB *Archive* spelled as stored" "2" "$(q "SELECT COUNT(*) FROM books WHERE title GLOB '*Archive*';")"
check "LIKE underscore matches exactly one character" "The Quiet Algorithm" \
  "$(q "SELECT title FROM books WHERE title LIKE 'The _____ Algorithm';")"
check "one underscore too few matches nothing, and does not error" "0" \
  "$(q "SELECT COUNT(*) FROM books WHERE title LIKE 'The ____ Algorithm';")"
check "GLOB character classes have no LIKE equivalent" "4" \
  "$(q "SELECT COUNT(*) FROM books WHERE title GLOB '[AN]*';")"
echo

echo "4. NULL and three-valued logic"
# An empty result string is how the sqlite3 shell renders a NULL scalar.
check "NULL = NULL is NULL, not 1"  "" "$(q 'SELECT NULL = NULL;')"
check "NULL <> NULL is NULL too"    "" "$(q 'SELECT NULL <> NULL;')"
check "NULL IS NULL is 1"           "1" "$(q 'SELECT NULL IS NULL;')"
check "NULL AND false is FALSE"     "0" "$(q 'SELECT NULL AND 0;')"
check "NULL AND true is UNKNOWN"    "" "$(q 'SELECT NULL AND 1;')"
check "NULL OR true is TRUE"        "1" "$(q 'SELECT NULL OR 1;')"
check "NULL OR false is UNKNOWN"    "" "$(q 'SELECT NULL OR 0;')"
check "NOT NULL is UNKNOWN"         "" "$(q 'SELECT NOT NULL;')"
check "the trap: returned_on = NULL finds nothing" "0" \
  "$(q 'SELECT COUNT(*) FROM loans WHERE returned_on = NULL;')"
check "the other trap: returned_on <> empty string finds the RETURNED ones" "30" \
  "$(q "SELECT COUNT(*) FROM loans WHERE returned_on <> '';")"
check "IS NULL is the only correct test" "15" \
  "$(q 'SELECT COUNT(*) FROM loans WHERE returned_on IS NULL;')"
check "naive not-from-Pune loses the members with no city" "8" \
  "$(q "SELECT COUNT(*) FROM members WHERE city <> 'Pune';")"
check "the honest version keeps them" "10" \
  "$(q "SELECT COUNT(*) FROM members WHERE city IS NULL OR city <> 'Pune';")"
check "and 8 + 2 Pune members would be 10, not 12 — so the naive query lost 2" "2" \
  "$(q "SELECT COUNT(*) FROM members WHERE city = 'Pune';")"
echo

echo "5. The NULL traps must NOT be 'fixed' by inventing data"
# These are the checks that go red if somebody makes the numbers agree by
# writing zeros and empty strings into the holes instead of understanding them.
check_ne "AVG(rating) must not equal the COALESCE-to-zero average" \
  "$(q 'SELECT ROUND(AVG(COALESCE(rating,0.0)),2) FROM books;')" \
  "$(q 'SELECT ROUND(AVG(rating),2) FROM books;')"
check "AVG(rating) ignores the 4 NULLs" "4.16" "$(q 'SELECT ROUND(AVG(rating),2) FROM books;')"
check "COALESCE to zero gives a different, wrong answer" "3.47" \
  "$(q 'SELECT ROUND(AVG(COALESCE(rating,0.0)),2) FROM books;')"
check "SUM over zero matching rows is NULL, not 0" "" \
  "$(q "SELECT SUM(rating) FROM books WHERE genre='no-such-genre';")"
check "TOTAL over the same zero rows is 0.0" "0.0" \
  "$(q "SELECT TOTAL(rating) FROM books WHERE genre='no-such-genre';")"
check "an aggregate over zero rows still returns exactly one row" "1" \
  "$(q 'SELECT COUNT(*) FROM (SELECT AVG(rating) FROM books WHERE published_year=1066);')"
# If a well-meaning fix replaced NULL ratings with 0.0 in the table itself, this
# MIN would become 0.0 and this check would fail.
check "MIN(rating) is a real rating, not an invented zero" "3.2" "$(q 'SELECT MIN(rating) FROM books;')"
echo

echo "6. ORDER BY, DISTINCT, LIMIT and OFFSET"
check "ascending puts NULLs first in SQLite" "" \
  "$(q 'SELECT rating FROM books ORDER BY rating ASC LIMIT 1;')"
check "descending puts them last" "4.9" \
  "$(q 'SELECT rating FROM books ORDER BY rating DESC LIMIT 1;')"
check "NULLS LAST overrides the ascending default" "3.2" \
  "$(q 'SELECT rating FROM books ORDER BY rating ASC NULLS LAST LIMIT 1;')"
check "so does ORDER BY rating IS NULL, rating" "3.2" \
  "$(q 'SELECT rating FROM books ORDER BY rating IS NULL, rating ASC LIMIT 1;')"
# genre ASC first, so the winner is the best book of the ALPHABETICALLY FIRST
# genre — fiction — not the best book overall. Key order is the whole meaning.
check "two sort keys: first key wins, so this is the top FICTION book" "The Long Instrument" \
  "$(q "SELECT title FROM books WHERE genre IS NOT NULL AND rating IS NOT NULL ORDER BY genre ASC, rating DESC LIMIT 1;")"
check "swap the keys and you get the best book overall instead" "Grammar of Machines" \
  "$(q "SELECT title FROM books WHERE genre IS NOT NULL AND rating IS NOT NULL ORDER BY rating DESC, genre ASC LIMIT 1;")"
check "the top MYSTERY needs the genre in the WHERE, not the ORDER BY" "The Second Archive" \
  "$(q "SELECT title FROM books WHERE genre='mystery' ORDER BY rating DESC NULLS LAST LIMIT 1;")"
check "ORDER BY may use a SELECT alias — standard SQL, works everywhere" "The Lost Cartographers" \
  "$(q 'SELECT title, pages*2 AS reading_minutes FROM books ORDER BY reading_minutes DESC LIMIT 1;' | cut -d'|' -f1)"
# SQLite ACCEPTS a SELECT alias in WHERE as an extension; standard SQL does not,
# and PostgreSQL rejects it. Pinning the behaviour here so the lesson's claim
# stays honest about which engine does what.
check "SQLite accepts a SELECT alias in WHERE, as an extension" "6" \
  "$(q 'SELECT COUNT(*) FROM (SELECT title, pages*2 AS reading_minutes FROM books WHERE reading_minutes > 800);')"
check "the portable spelling gives the same six rows" "6" \
  "$(q 'SELECT COUNT(*) FROM (SELECT title, pages*2 AS reading_minutes FROM books WHERE pages*2 > 800);')"
check "DISTINCT over one column: distinct authors" "7" \
  "$(q 'SELECT COUNT(*) FROM (SELECT DISTINCT author FROM books);')"
check "DISTINCT over two columns keeps one row per PAIR" "15" \
  "$(q 'SELECT COUNT(*) FROM (SELECT DISTINCT author, genre FROM books);')"
check "top-N with a deterministic tie-break" "Small Gods of Arithmetic" \
  "$(q 'SELECT title FROM books ORDER BY rating DESC NULLS LAST, title ASC LIMIT 1;')"
check "OFFSET 5 starts page two of the same list" "The Silent Archive" \
  "$(q 'SELECT title FROM books ORDER BY rating DESC NULLS LAST, title ASC LIMIT 1 OFFSET 5;')"
echo

echo "7. Aggregates, and what NULL does to each"
check "COUNT(*) counts rows"              "24" "$(q 'SELECT COUNT(*) FROM books;')"
check "COUNT(rating) counts values"       "20" "$(q 'SELECT COUNT(rating) FROM books;')"
check "COUNT(genre) counts values"        "21" "$(q 'SELECT COUNT(genre) FROM books;')"
check "COUNT(DISTINCT genre) skips NULL"  "5"  "$(q 'SELECT COUNT(DISTINCT genre) FROM books;')"
check "COUNT(DISTINCT author)"            "7"  "$(q 'SELECT COUNT(DISTINCT author) FROM books;')"
check "AVG is SUM over the NON-NULL count" "1" \
  "$(q 'SELECT ROUND(AVG(rating),6) = ROUND(SUM(rating)/COUNT(rating),6) FROM books;')"
check "MIN(published_year)" "1988" "$(q 'SELECT MIN(published_year) FROM books;')"
check "MAX(published_year)" "2025" "$(q 'SELECT MAX(published_year) FROM books;')"
echo

echo "8. GROUP BY"
check "one bucket per distinct genre, NULLs together in one more" "6" \
  "$(q 'SELECT COUNT(*) FROM (SELECT genre FROM books GROUP BY genre);')"
check "the science bucket has 7 books" "7" \
  "$(q "SELECT COUNT(*) FROM books GROUP BY genre HAVING genre='science';")"
check "the unclassified bucket has 3" "3" \
  "$(q 'SELECT COUNT(*) FROM books GROUP BY genre HAVING genre IS NULL;')"
check "grouping by an expression: books published in the 2010s" "9" \
  "$(q 'SELECT n FROM (SELECT (published_year/10)*10 AS decade, COUNT(*) AS n FROM books WHERE published_year IS NOT NULL GROUP BY decade) WHERE decade=2010;')"
check "grouping by two keys gives one row per combination that occurs" "15" \
  "$(q 'SELECT COUNT(*) FROM (SELECT author, genre FROM books GROUP BY author, genre);')"
check "the busiest borrower took 6 loans" "6" \
  "$(q 'SELECT COUNT(*) AS n FROM loans GROUP BY member_id ORDER BY n DESC LIMIT 1;')"
check "SUM over a CASE counts a subset inside each bucket" "15" \
  "$(q 'SELECT SUM(still) FROM (SELECT SUM(CASE WHEN returned_on IS NULL THEN 1 ELSE 0 END) AS still FROM loans GROUP BY member_id);')"
check "WHERE runs before GROUP BY: Q1 loans for member 1" "5" \
  "$(q "SELECT COUNT(*) FROM loans WHERE member_id=1 AND borrowed_on BETWEEN '2026-01-01' AND '2026-03-31';")"
echo

echo "9. HAVING — the filter WHERE cannot express"
check "authors with more than three titles" "3" \
  "$(q 'SELECT COUNT(*) FROM (SELECT author FROM books GROUP BY author HAVING COUNT(*) > 3);')"
check "the most prolific of them" "Ada Fenwick" \
  "$(q 'SELECT author FROM books GROUP BY author ORDER BY COUNT(*) DESC, author ASC LIMIT 1;')"
# The proof that WHERE cannot do this: the engine refuses the query outright.
where_err="$(sqlite3 "${db}" 'SELECT author FROM books WHERE COUNT(*) > 3 GROUP BY author;' 2>&1)"
if printf '%s' "${where_err}" | grep -qi 'misuse of aggregate'; then
  pass "WHERE COUNT(*) > 3 is rejected: ${where_err}"
else
  fail "WHERE COUNT(*) > 3 should be rejected as a misuse of an aggregate, but SQLite said: ${where_err}"
fi
check "WHERE and HAVING together: post-2000 genres with 2+ books" "6" \
  "$(q 'SELECT COUNT(*) FROM (SELECT genre FROM books WHERE published_year>=2000 GROUP BY genre HAVING COUNT(*)>=2);')"
check "HAVING on an aggregate absent from the SELECT list" "2" \
  "$(q 'SELECT COUNT(*) FROM (SELECT genre FROM books GROUP BY genre HAVING AVG(pages) > 350);')"
check "books borrowed more than twice" "7" \
  "$(q 'SELECT COUNT(*) FROM (SELECT book_id FROM loans GROUP BY book_id HAVING COUNT(*) > 2);')"
check "the most borrowed book was taken out 7 times" "7" \
  "$(q 'SELECT COUNT(*) AS n FROM loans GROUP BY book_id ORDER BY n DESC LIMIT 1;')"
check "HAVING over two aggregates: members with 2+ books still out" "5" \
  "$(q 'SELECT COUNT(*) FROM (SELECT member_id FROM loans GROUP BY member_id HAVING COUNT(*)-COUNT(returned_on) >= 2);')"
echo

echo "10. Scalar functions and CASE"
check "|| concatenates" "The Silent Archive (Priya Raman)" \
  "$(q "SELECT title || ' (' || author || ')' FROM books WHERE book_id=1;")"
check "a scalar function on NULL returns NULL" "" "$(q 'SELECT LOWER(genre) FROM books WHERE book_id=15;')"
check "TYPEOF names the storage class of the value in the row" "null" \
  "$(q 'SELECT TYPEOF(rating) FROM books WHERE book_id=4;')"
check "JULIANDAY subtraction gives real elapsed days" "28.0" \
  "$(q 'SELECT JULIANDAY(returned_on)-JULIANDAY(borrowed_on) FROM loans WHERE loan_id=2;')"
check "subtracting the raw TEXT dates confidently returns nonsense" "0" \
  "$(q 'SELECT returned_on - borrowed_on FROM loans WHERE loan_id=2;')"
check "STRFTIME buckets a stored date by month" "2026-01" \
  "$(q "SELECT STRFTIME('%Y-%m', borrowed_on) FROM loans WHERE loan_id=1;")"
check "GROUP BY over a CASE: the 'good' band" "8" \
  "$(q "SELECT n FROM (SELECT CASE WHEN rating IS NULL THEN 'unrated' WHEN rating>=4.5 THEN 'excellent' WHEN rating>=4.0 THEN 'good' WHEN rating>=3.5 THEN 'fair' ELSE 'poor' END AS band, COUNT(*) AS n FROM books GROUP BY band) WHERE band='good';")"
check "with the NULL branch first, 4 books are 'unrated'" "4" \
  "$(q "SELECT COUNT(*) FROM (SELECT CASE WHEN rating IS NULL THEN 'unrated' WHEN rating>=4.0 THEN 'good' ELSE 'poor' END AS band FROM books) WHERE band='unrated';")"
check "with the NULL branch, 'poor' holds the 6 genuinely low-rated books" "6" \
  "$(q "SELECT COUNT(*) FROM (SELECT CASE WHEN rating IS NULL THEN 'unrated' WHEN rating>=4.0 THEN 'good' ELSE 'poor' END AS band FROM books) WHERE band='poor';")"
# Drop the NULL branch and 'poor' swells from 6 to 10: the 4 unrated books fall
# through the ELSE and are quietly reported as the worst books in the library.
check "without it, 'poor' swells to 10 as the 4 unrated books fall through" "10" \
  "$(q "SELECT COUNT(*) FROM (SELECT CASE WHEN rating>=4.0 THEN 'good' ELSE 'poor' END AS band FROM books) WHERE band='poor';")"
echo

echo "11. Every example query file runs against a fresh database"
for f in "${lab_dir}"/examples/queries/*.sql; do
  name="$(basename "${f}")"
  if err="$(sqlite3 "${db}" < "${f}" 2>&1 >/dev/null)" && [ -z "${err}" ]; then
    pass "examples/queries/${name} runs clean"
  else
    fail "examples/queries/${name} produced errors: ${err}"
  fi
done
echo

echo "12. The from-scratch GROUP BY agrees with the one-line SQL"
gb_out="$("${python_bin}" "${lab_dir}/examples/groupby_from_scratch.py" "${db}" 2>&1)"
gb_status=$?
if [ "${gb_status}" -eq 0 ]; then pass "groupby_from_scratch.py exits 0"
else fail "groupby_from_scratch.py exited ${gb_status}"; fi
if printf '%s' "${gb_out}" | grep -q 'IDENTICAL: 3 rows match exactly.'; then
  pass "the Python accumulators and the SQL agree on all 3 rows"
else
  fail "the Python and SQL results did not match: $(printf '%s' "${gb_out}" | tail -3)"
fi
check "the pipeline it prints: FROM 24 rows" "  FROM      -> 24 rows" \
  "$(printf '%s\n' "${gb_out}" | grep 'FROM ')"
check "WHERE keeps 20" "  WHERE     -> 20 rows survive" \
  "$(printf '%s\n' "${gb_out}" | grep 'WHERE ')"
check "GROUP BY makes 6 buckets" "  GROUP BY  -> 6 buckets" \
  "$(printf '%s\n' "${gb_out}" | grep 'GROUP BY ')"
check "HAVING leaves 3" "  HAVING    -> 3 buckets survive" \
  "$(printf '%s\n' "${gb_out}" | grep 'HAVING ')"
echo

echo "13. The exercises: the answer key is right and the starter is wrong"
answers="$(sqlite3 "${db}" < "${lab_dir}/examples/exercise-answers.sql" 2>&1)"
starter="$(sqlite3 "${db}" < "${lab_dir}/starter/exercises.sql" 2>&1)"
check "the answer key emits 12 labelled lines" "12" "$(printf '%s\n' "${answers}" | grep -c '^ex[0-9][0-9]|')"
check "the starter emits the same 12 labels"   "12" "$(printf '%s\n' "${starter}" | grep -c '^ex[0-9][0-9]|')"

expect_answer() {
  check "answer $1" "$2" "$(printf '%s\n' "${answers}" | grep "^$1|" | cut -d'|' -f2-)"
}
expect_answer ex01 "15"
expect_answer ex02 "10"
expect_answer ex03 "2"
expect_answer ex04 "4"
expect_answer ex05 "4.16"
expect_answer ex06 "4"
expect_answer ex07 "Ledger of Tides"
expect_answer ex08 "6"
expect_answer ex09 "Ada Fenwick"
expect_answer ex10 "3"
expect_answer ex11 "28.0"
expect_answer ex12 "4"

# Every starter answer must DIFFER from the model answer. If a starter query
# ever accidentally became right, the exercise would teach nothing.
disagreements=0
for label in ex01 ex02 ex03 ex04 ex05 ex06 ex07 ex08 ex09 ex10 ex11 ex12; do
  a="$(printf '%s\n' "${answers}" | grep "^${label}|" | cut -d'|' -f2-)"
  s="$(printf '%s\n' "${starter}" | grep "^${label}|" | cut -d'|' -f2-)"
  [ "${a}" != "${s}" ] && disagreements=$((disagreements + 1))
done
check "all 12 starter queries return a WRONG answer before you fix them" "12" "${disagreements}"
echo

echo "14. The lab stays offline, stays out of your way, and cleans up"
if grep -rInE 'https?://[a-zA-Z0-9]' "${lab_dir}/examples" "${lab_dir}/starter" "${lab_dir}/tests" >/dev/null 2>&1; then
  fail "something under examples/, starter/ or tests/ names a URL"
else
  pass "no URL anywhere in examples/, starter/ or tests/"
fi
if grep -rIn 'sudo' "${lab_dir}/examples" "${lab_dir}/starter" >/dev/null 2>&1; then
  fail "an example or starter file calls sudo"
else
  pass "nothing under examples/ or starter/ calls sudo"
fi
if [ -e "${lab_dir}/library.db" ]; then
  fail "a database was left in the lab root; it belongs under examples/"
else
  pass "no stray database in the lab root"
fi
if grep -q 'library.db' "${lab_dir}/.gitignore" 2>/dev/null; then
  pass "the built database is git-ignored, so it is never committed"
else
  fail ".gitignore does not exclude the built database"
fi
echo

echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ] || exit 1
exit 0

Troubleshooting

Troubleshooting

Almost every problem in this lab is the same problem wearing a different hat: the query ran, it printed something, and the something was wrong. SQL will not warn you. These are the shapes that mistake takes.

sqlite3: command not found

The shell is not installed. On macOS it ships with the system; on Debian and Ubuntu the shell is a separate package from the library, so import sqlite3 can work in Python while the command does not exist:

sudo apt install sqlite3      # Debian, Ubuntu
sudo dnf install sqlite       # Fedora

tests/run_tests.sh checks for it first and stops with that instruction rather than failing halfway through.

Error: unable to open database file

You are not in the lab directory, or the database has not been built. Both are fixed the same way:

cd labs/sections/programming-with-python/day-086-select-filtering-sorting-and-aggregating
bash examples/build_db.sh

Every command in this lab's README is written to be run from the lab directory, and every path in it is relative to that directory.

Error: no such table: books

You opened a database that exists but is empty. That happens when you type a database name SQLite has never seen: sqlite3 libary.db (note the typo) does not fail — it cheerfully creates a brand-new empty file with that name. Delete the stray file and rebuild:

ls *.db examples/*.db
rm -f examples/library.db
bash examples/build_db.sh

A query returns 0 rows and you are sure it should not

Nine times out of ten in this lab, a NULL is involved. Work through these in order:

  1. Are you comparing to NULL with = or <>? Both are UNKNOWN for every row, and WHERE keeps only rows where the predicate is TRUE. Use IS NULL or IS NOT NULL.
  2. Is the column you filtered on nullable, and did you write a negative filter? WHERE city <> 'Pune' silently drops every member whose city is NULL. Write WHERE city IS NULL OR city <> 'Pune' when you mean to keep them.
  3. Are you using NOT IN on a nullable column? Same trap, same fix.
  4. Did you use GLOB when you meant LIKE? GLOB is case-sensitive and uses * and ?; LIKE folds case for ASCII letters and uses % and _.

To see it rather than reason about it:

sqlite3 -header -column examples/library.db < examples/queries/03-null-traps.sql

Error: in prepare, misuse of aggregate: COUNT()

You put an aggregate in WHERE. WHERE runs before the rows are grouped, so at that moment there is no group for COUNT(*) to count. The clause that filters groups is HAVING:

-- rejected
SELECT author, COUNT(*) FROM books WHERE COUNT(*) > 3 GROUP BY author;
-- correct
SELECT author, COUNT(*) FROM books GROUP BY author HAVING COUNT(*) > 3;

A SELECT alias used in WHERE works here and breaks somewhere else

This is the trap in reverse, and it is worth reading carefully because it is the one that costs you a day rather than a minute.

WHERE is evaluated before SELECT, so by the rules of standard SQL an alias invented in the SELECT list does not exist yet and a query using it in WHERE must be rejected. PostgreSQL rejects it. SQLite accepts it, as a documented extension. Verified on the authoring machine with sqlite3 3.51.0:

$ sqlite3 examples/library.db 'SELECT title, pages*2 AS reading_minutes FROM books WHERE reading_minutes > 800;'
Grammar of Machines|960
Salt and Longitude|1056
The Lost Cartographers|1224
Continental Drift Blues|842
Coasts of Elsewhere|910
The Long Instrument|1024

So the query you wrote today runs perfectly, and the same file moved to PostgreSQL next year does not. If you want the query to be portable, repeat the expression or wrap it — both are accepted everywhere:

-- portable, repeating the expression
SELECT title, pages*2 AS reading_minutes FROM books WHERE pages*2 > 800;
-- portable, computing it in an inner query first
SELECT * FROM (SELECT title, pages*2 AS reading_minutes FROM books)
WHERE reading_minutes > 800;

ORDER BY is the opposite case: using an alias there is standard SQL and works on every engine, precisely because ORDER BY runs after SELECT.

ORDER BY ... NULLS LAST is rejected

Your SQLite predates 3.30 (2019). Use the portable form, which works on every version and on other engines too:

ORDER BY rating IS NULL, rating ASC

rating IS NULL evaluates to 0 for the rows that have a value and 1 for the rows that do not, so sorting on it ascending puts the real values first.

An average looks too low

You almost certainly wrapped the column in COALESCE(col, 0). That does not "handle" the missing ratings; it invents four books rated zero and mixes them into the arithmetic. In this database it moves the average from 4.16 to 3.47, and nothing anywhere says so.

AVG already ignores NULLs. If you want to know how much data the average is actually based on, ask for it:

SELECT COUNT(*) AS rows, COUNT(rating) AS rated, AVG(rating) FROM books;

bash starter/check.sh says a numeric answer is wrong but the number looks right

Compare the text, not the value. 28 and 28.0 are different strings, and check.sh compares strings because that is what the shell has. Exercise 11 requires 28.0, which is what JULIANDAY(...) - JULIANDAY(...) returns — a real number of days. If yours prints 28, you have probably rounded or cast it.

bash tests/run_tests.sh fails on a check you did not touch

The harness builds its own throwaway database under mktemp -d and never reads examples/library.db, so it cannot be affected by anything you did to your copy. A failure there means examples/seed.sql, an example query, or the answer key has genuinely changed. Read the failing line: it prints the expected value and the actual one side by side.

Everything works but the lab directory has a library.db in it

An older command built it in the wrong place. The database belongs under examples/, it is git-ignored there, and tests/run_tests.sh has a check that fails if one appears in the lab root:

rm -f library.db
bash examples/build_db.sh

Windows

Use WSL and follow the Linux instructions. The three shell scripts here are bash scripts, and mktemp -d and trap behave as they do on Linux inside WSL. On native Windows you can still run every .sql file by hand through the Windows build of the sqlite3 shell — the SQL is identical — but tests/run_tests.sh, examples/build_db.sh and starter/check.sh will not run. That path has not been executed on the authoring machine, so it is described rather than promised.

Security notes

Security notes

This lab reads a local file with a local tool and never opens a socket. The security surface is small, and it is worth being precise about where the real risk in querying lives, because it is not where beginners expect.

What this lab does and does not touch

  • No network. Nothing here resolves a hostname or opens a connection. The test suite has a check that fails if any file under examples/, starter/ or tests/ so much as contains a URL.
  • No credentials. SQLite has no users, no passwords and no GRANT. Access to the data is exactly filesystem access to the file. That simplicity is a feature here and a limitation in production, and the lesson says so.
  • No sudo. The only privileged command anywhere in this lab is the optional apt/dnf install of the sqlite3 shell, in the troubleshooting notes, for machines that do not have it. Nothing in the lab runs it for you.
  • No daemon, no port, no background process. SQLite is a library and a file. When the shell exits, nothing is left running.
  • Nothing outside the lab directory. The seed writes examples/library.db; the test harness writes only inside a mktemp -d directory it removes in a trap. Both are deletable with rm.

The real risk in this topic: string-built SQL

Every query in this lab is a fixed file. The moment you build a query by pasting a value into it — which is the very next thing anyone does — you have created the most common serious vulnerability in application software.

## NEVER do this. The value is CODE, not data.
cur.execute("SELECT * FROM books WHERE author = '" + name + "'")

If name is x' OR '1'='1, that query returns the whole table. If it contains a statement separator and your driver executes more than one statement, it can do considerably worse. The fix is not to escape quotes yourself — people have been getting that wrong for thirty years. The fix is to never build the string:

## Parameterised. The value can never be parsed as SQL.
cur.execute("SELECT * FROM books WHERE author = ?", (name,))

Python's sqlite3 module takes ? placeholders, and a tuple of values. The driver sends the query text and the values along separate paths, so no value can change the shape of the statement no matter what characters it contains.

examples/groupby_from_scratch.py uses a fixed query string with no interpolation at all, which is the other correct answer: when there is no user input, there is nothing to inject.

The thing that is not a defence: a WHERE clause is not an access control. It filters what a query returns; it does not stop the same connection from running a different query without it. If a piece of code must not see certain rows, that has to be enforced by what the code is allowed to connect to, not by the text of the queries you hope it will write.

A privacy point that belongs to today specifically

This lesson is about aggregation, and aggregation is routinely offered as a privacy measure: "we only publish counts, never individual records." Treat that claim carefully. A GROUP BY whose buckets are small does not anonymise anything — a count of 1 in a bucket identifies exactly one person, and two published aggregates that differ by one row tell you what that row contained. The habit worth forming now, while the stakes are a fictional library, is to look at the smallest bucket in any grouped result before publishing it.

The seed data here is invented. The member names are fictional and every email address uses the .invalid top-level domain, which RFC 2606 reserves permanently so that it can never be registered and never routes anywhere.

Deleting everything

rm -f examples/library.db

That is the entire cleanup. There is no service to stop, no package to remove, no configuration to revert, and nothing was written outside this directory.