By the end you'll know
- Why a check is a query whose right answer is no rows
- How to collect many checks into one view with
UNION ALL - How to check a rule across rows, like overlapping terms, that no constraint can express
- The difference between an error that must stop the work and a warning for a person to read
- How a script turns the checks into a pass or fail that other steps can rely on
Before you start
- You need: Lessons 1 to 9 done, with the
larkspurcontainer running. - Get the new files: in your
sql-practicefolder,git pull. You should now havechecks.sqlandcheck.shnext tolarkspur.sql. - Where commands run: in your computer's terminal, in
sql-practice. This lesson runs psql from scripts, not by hand.
Constraints, and the things they can't say
Lesson 6 put rules in the tables, and Lesson 7 added them to the county once its data was clean. Constraints are the best kind of rule: nothing can get past them. But they can only say certain things. A CHECK sees one row. UNIQUE sees one value, or one combination. Plenty of real rules are bigger than that:
- Two mayors of the same town in office at the same time, even in the past. That compares the dates in one row with the dates in another.
- A town with no current mayor. That's about a row that isn't there.
- A website that isn't
https. Lesson 6's "Spot the problem" showed why that shouldn't be a constraint: it would refuse a true fact.
For those, there's the other kind of rule, the one Lessons 3 to 8 have been writing all along: a query that finds the rows breaking the rule. If it returns no rows, the rule holds. Every one of them has the same shape, so they can be collected, run together, and run every time.
Think of a pre-flight checklist. The plane is built to be safe: that's the constraints. Before every flight, the crew still walks through a list (fuel, flaps, doors), and each item has one right answer. Nobody skips it because the plane passed last time. A check is the same: cheap to run, run every time, and it only matters on the day something's wrong.
The checks, as one view
Open checks.sql. It's one view: a saved query with a name, which you can select from like a table. Each part of it is one check, and each check returns the same four columns, so they can be stacked with UNION ALL, which joins the rows of several queries into one result.
Here's the first, from Lesson 4:
CREATE OR REPLACE VIEW problems AS
-- one current mayor per town
SELECT 'error' AS severity, 'two_current_mayors' AS check_name,
'town ' || town_id AS ref, count(*) || ' current mayors' AS detail
FROM leaders
WHERE role = 'mayor' AND ended IS NULL
GROUP BY town_id HAVING count(*) > 1
Every check returns:
severity:errorfor a rule that must always hold,warnfor something a person should look at.check_name: which rule, so you know what broke.ref: which rows, by key, so you can find them.detail: enough to understand the problem without another query.
Then UNION ALL, and the next check, from Lesson 3:
UNION ALL
-- a term can't end before it starts
SELECT 'error', 'term_order', 'leader ' || id, name || ': ' || started || ' to ' || ended
FROM leaders
WHERE ended < started
Only the first part names the columns; the rest just line up with them. The file goes on the same way: names with spaces (Lesson 3), the phone format (Lesson 7), shared phones with the empty ones left out (Lessons 4 and 8), towns with no current mayor using NOT EXISTS (Lesson 5), websites that are missing or not https, and populations that are unknown.
A rule no constraint can make
One check is new. Two mayors of the same town whose terms overlap:
UNION ALL
-- two mayors of the same town at the same time, past or present
SELECT 'error', 'overlapping_mayors', 'leaders ' || a.id || ' and ' || b.id,
btrim(a.name) || ' and ' || btrim(b.name) || ', town ' || a.town_id
FROM leaders a
JOIN leaders b ON b.town_id = a.town_id AND b.id > a.id
WHERE a.role = 'mayor' AND b.role = 'mayor'
AND a.started < coalesce(b.ended, 'infinity')
AND b.started < coalesce(a.ended, 'infinity')
Three ideas in it:
- A table joined to itself.
leaders a JOIN leaders bpairs each leader with every other leader of the same town.b.id > a.idkeeps each pair once, and stops a leader being paired with themselves. - The overlap test. Two periods overlap when each starts before the other ends. That's the whole of it:
a.started < b.ended AND b.started < a.ended. coalesce(ended, 'infinity'). A current mayor has no end date, which means "still going".'infinity'is a real date value in Postgres, later than any other, so a term with no end is treated as one that never ends. Without it, Lesson 8's rule would strike: comparing with NULL is unknown, and current mayors would never be found overlapping anyone.
The comparisons are strict, < and not <=, so a term that ends on the same day the next one starts, a handover, doesn't count as an overlap.
Errors and warnings
Not every problem means the data is wrong. Two mayors at once is impossible: that's an error. A town with no current mayor might be true. Maybe the office is vacant, or maybe nobody has recorded the new one, and a person has to find out. That's a warning.
The split matters because of what happens next. An error should stop whatever is running: an import, a bulk update, a deployment. A warning should be read, and never stop anything, or people learn to ignore the checks. That's why severity is a column, and why the script below only fails on errors.
The script
check.sh loads the view, prints every problem, and decides:
#!/usr/bin/env bash
# Run the Larkspur County checks against the larkspur container.
# Prints every problem found. Exits 1 if any error-level check finds a row.
set -euo pipefail
psql_() { docker exec larkspur psql -U postgres -d larkspur -v ON_ERROR_STOP=1 "$@"; }
psql_ -q -f /practice/checks.sql
psql_ -c "SELECT severity, check_name, ref, detail FROM problems ORDER BY severity, check_name, ref;"
errors=$(psql_ -tA -c "SELECT count(*) FROM problems WHERE severity = 'error';")
warnings=$(psql_ -tA -c "SELECT count(*) FROM problems WHERE severity = 'warn';")
if [ "$errors" -gt 0 ]; then
echo "FAIL: $errors error(s), $warnings warning(s)"
exit 1
fi
echo "PASS: 0 errors, $warnings warning(s)"
The new parts, if you haven't done the Linux track:
-v ON_ERROR_STOP=1makes psql stop, and fail, at the first SQL error. Without it, psql carries on past errors, as you saw in every lesson's batch, and a broken check would look like a passing one.-tAprints just the value: no header, no lines, no(1 row). That's what you want when a script reads the answer, here intoerrors.exit 1is how a script says "failed". Every command finishes with an exit code: 0 means success, anything else means failure. Other scripts, and tools like CI pipelines, read it to decide whether to carry on.
Run it on the original county
Reload the county with all its mistakes, then run the checks:
docker exec larkspur psql -U postgres -q -f /practice/larkspur.sql
./check.sh; echo "exit code: $?"
severity | check_name | ref | detail
----------+--------------------+-------------------+----------------------------------------------
error | name_spaces | leader 8 | "Sam Whitfield "
error | overlapping_mayors | leaders 19 and 20 | Joy Hart and Carl Webb, town 12
error | overlapping_mayors | leaders 9 and 10 | Grace Lin and Owen Price, town 5
error | phone_format | town 7 | Fairview: 555-010-7700
error | shared_phone | towns 4, 5 | (555) 010-4400
error | term_order | leader 20 | Carl Webb: 2024-01-01 to 2023-06-30
error | two_current_mayors | town 5 | 2 current mayors
warn | no_current_mayor | town 10 | Juniper
warn | no_current_mayor | town 8 | Glen Oak
warn | population_missing | town 5 | Dunmore
warn | website_not_https | town 10 | Juniper: (none)
warn | website_not_https | town 2 | Ashford: (none)
warn | website_not_https | town 4 | Cedar Hollow: http://cedarhollow.larkspur.us
(13 rows)
FAIL: 7 error(s), 6 warning(s)
exit code: 1
Every problem from Lessons 3 to 8, in one run. Seven errors and six warnings, each with the key to find it. $? holds the exit code of the last command: 1, failed.
The quotes in "Sam Whitfield " are there on purpose, so the trailing space can be seen. And town 10 sorts before town 2: ref is text, and text sorts one character at a time, Lesson 8's trap, harmless here.
Dunmore's two current mayors show up twice: as two_current_mayors, and as overlapping_mayors, since two people in office now overlap. Two checks catching the same fact from different angles is normal, and fine.
After Lesson 7's fixes
Apply the same fixes as Lesson 7, in one transaction:
docker exec larkspur psql -U postgres -d larkspur -c "
BEGIN;
UPDATE leaders SET name = btrim(name) WHERE id = 8;
UPDATE leaders SET ended = '2023-01-01' WHERE id = 9;
UPDATE leaders SET started = '2023-01-01' WHERE id = 20;
UPDATE towns SET phone = '(555) 010-7700' WHERE id = 7;
UPDATE towns SET phone = '(555) 010-5500' WHERE id = 5;
COMMIT;"
./check.sh; echo "exit code: $?"
BEGIN
UPDATE 1
UPDATE 1
UPDATE 1
UPDATE 1
UPDATE 1
COMMIT
severity | check_name | ref | detail
----------+--------------------+-------------------+----------------------------------------------
error | overlapping_mayors | leaders 19 and 20 | Joy Hart and Carl Webb, town 12
warn | no_current_mayor | town 10 | Juniper
warn | no_current_mayor | town 8 | Glen Oak
warn | population_missing | town 5 | Dunmore
warn | website_not_https | town 10 | Juniper: (none)
warn | website_not_https | town 2 | Ashford: (none)
warn | website_not_https | town 4 | Cedar Hollow: http://cedarhollow.larkspur.us
(7 rows)
FAIL: 1 error(s), 6 warning(s)
exit code: 1
Six errors fixed. One left, and it's one Lesson 7 created. Fixing Carl Webb's start year made his term valid, early 2023, and every rule Lesson 7 added was happy with it. But Joy Hart has been Linden's mayor since 2022. Look at the two of them:
docker exec larkspur psql -U postgres -d larkspur -c "SELECT id, name, started, ended FROM leaders WHERE town_id = 12 ORDER BY started;"
id | name | started | ended
----+-----------+------------+------------
19 | Joy Hart | 2022-01-01 |
20 | Carl Webb | 2023-01-01 | 2023-06-30
(2 rows)
Joy Hart's term starts in 2022 and never ends; Carl Webb's sits inside it. Two mayors at once, which the constraints couldn't see and the check could. This is the real value of running checks after every change: a fix can create a new problem, and the rules you thought of last time won't catch it.
Fix it, and pass
Which record is wrong? Again, that needs a source. Say Linden's records show Joy Hart took over the day Carl Webb left:
docker exec larkspur psql -U postgres -d larkspur -c "UPDATE leaders SET started = '2023-06-30' WHERE id = 19 RETURNING id, name, started, ended;"
./check.sh; echo "exit code: $?"
id | name | started | ended
----+----------+------------+-------
19 | Joy Hart | 2023-06-30 |
(1 row)
UPDATE 1
severity | check_name | ref | detail
----------+--------------------+---------+----------------------------------------------
warn | no_current_mayor | town 10 | Juniper
warn | no_current_mayor | town 8 | Glen Oak
warn | population_missing | town 5 | Dunmore
warn | website_not_https | town 10 | Juniper: (none)
warn | website_not_https | town 2 | Ashford: (none)
warn | website_not_https | town 4 | Cedar Hollow: http://cedarhollow.larkspur.us
(6 rows)
PASS: 0 errors, 6 warning(s)
exit code: 0
PASS, exit code 0. Carl Webb ends on 30 June and Joy Hart starts on 30 June: a handover, not an overlap, because the comparison is strict. The six warnings remain, listed every run, for a person to work through: two towns to research a mayor for, one population to find, three websites to look at.
Checks as a gate
The exit code is what makes the checks useful to other steps. && runs the next command only if the last one succeeded; || runs it only if the last one failed:
./check.sh > /dev/null && echo "checks passed: safe to carry on" || echo "checks failed: stop"
checks passed: safe to carry on
(> /dev/null throws away the table, to keep just the verdict.) Put ./check.sh && in front of whatever comes next, a publish, an export, the next stage of an import, and it only runs on good data.
In real systems
- CivicGrid runs four checks after every bulk change, and stops if any of them finds a row: every city's metro area contains its own state, the population density is believable, every phone is in the right format, and no two cities share a phone. They're queries, like these, with the same rule: a check that finds a row stops the work.
- Checks also run on a schedule. Data changes for reasons nobody planned: someone edits a row by hand, a source changes. A nightly run that reports to a channel catches what the after-change runs couldn't.
- The same idea in CI. A pipeline runs tests and fails the build on any failure, by exit code. Data checks in a pipeline are tests for the data, and
check.shwould drop straight into one.
Spot the problem
Work out what's wrong before you read the explanation.
The check that always passes. Someone adds a check for meetings held before their town was founded, and it never finds anything, for months. Its query is SELECT ... FROM meetings m JOIN towns t ON t.id = m.town_id WHERE extract(year FROM m.held_on) < t.founded, in the view, and check.sh passes every time. Then someone tries a deliberate bad row, a meeting in 1700, to test it, and it still passes. What's wrong? Look at the script: errors are counted with WHERE severity = 'error'. If the new check's severity was written as 'Error', or 'err', its rows are in the view but never counted, and the script says PASS. Test every check by breaking the rule on purpose, once, and seeing it fail. A check you've never seen fail might not be checking anything. (A CHECK (severity IN ('error', 'warn')) can't go on a view, but a quick query over problems for any other severity can be a check of its own.)
Keep this
| To | Write |
|---|---|
| Run the checks | ./check.sh, in sql-practice |
| See the last exit code | echo $? |
| Carry on only if they pass | ./check.sh && next-step |
| A saved query | CREATE OR REPLACE VIEW problems AS SELECT ...; |
| Stack query results | SELECT ... UNION ALL SELECT ... |
| Pair rows of one table | FROM leaders a JOIN leaders b ON b.town_id = a.town_id AND b.id > a.id |
| Do two periods overlap? | a.started < coalesce(b.ended, 'infinity') AND b.started < coalesce(a.ended, 'infinity') |
| Stop psql at the first error | psql -v ON_ERROR_STOP=1 |
| Just the value, for a script | psql -tA -c 'SELECT count(*) ...' |
Where you are now
You have a set of checks that run in one command, find every problem from Lessons 3 to 8 and one more, and fail when the data is wrong. Your county is clean now, apart from the warnings. Lesson 11 is about who can see and change it: roles, permissions, and row-level security.
Check yourself
What's the right answer to a check query?
Why can't 'two mayors of a town in office at the same time' be a CHECK constraint?
Why does the overlap check use coalesce(ended, 'infinity')?
Why is 'no current mayor' a warning and not an error?
After Lesson 7's fixes, every constraint passed but the checks still failed. Why?
What does ./check.sh && publish.sh do when a check finds an error?
Key terms
- Check: a query that returns the rows breaking a rule. No rows means the rule holds.
- View: a saved query with a name, which can be selected from like a table.
- UNION ALL: stacks the rows of several queries with the same columns into one result.
- Self-join: a table joined to itself, to compare rows with each other.
- Error and warning: a problem that must stop the work, and one a person should read.
- Exit code: the number a command finishes with. 0 means success; anything else, failure.
- Gate: a step that only lets the next one run if it passes.
Go deeper
- The PostgreSQL manual: Views (opens in a new tab), and Combining Queries (opens in a new tab) for
UNION. - Try: add a check that no meeting has a negative attendance, as an error. Then break it on purpose with an
UPDATEin a transaction, run./check.sh, andROLLBACK. - Try: add a warning for meetings with no minutes link. How many does it find? Is that a warning you'd want to see every run?