Lesson 3 · Foundations
Querying: SELECT, JOIN, aggregate
Reading data — the SQL you'll see in every repository.
Your win: read and write the everyday query — filter with
WHERE, combine tables with JOIN, summarise with
GROUP BY — matching the real queries in spike and conversationmgmt.
SELECT: choose columns, filter rows
SELECT subject, status
FROM emails
WHERE status = $1 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 20;
Read it as: pick these columns, from this table, keep rows matching
WHERE, sort, and cap the count. Values are always passed as
parameters ($1), never string-concatenated — that's how our
repos avoid SQL injection.1
Your real spike query filters by an id list and the soft-delete flag:
internal/spike/.../repositories/email.go:196SELECT ... FROM emails e
WHERE e.email_id = ANY($1) -- match any id in the array param
AND e.deleted_at IS NULL; -- exclude soft-deleted rows
JOIN: combine rows across tables
An email has many recipients in a separate table. A JOIN stitches them
together on the shared key (the foreign key from Lesson 1):
SELECT e.subject, r.recipient_address
FROM emails e
JOIN email_recipients r ON r.email_id = e.email_id -- INNER JOIN
WHERE e.deleted_at IS NULL;
Aggregate: summarise with GROUP BY
Aggregate functions (count, sum, avg, …) collapse
many rows into one; GROUP BY makes one result per group:
SELECT status, count(*) AS n
FROM emails
WHERE deleted_at IS NULL
GROUP BY status
HAVING count(*) > 5; -- HAVING filters groups (WHERE filters rows)
SELECT COUNT(*) FROM ( … ) tmp_count
(support_conversation.go CountByFilter) — a
common pattern for "how many match this filter?" alongside the paginated list. And every
read you'll see ends with deleted_at IS NULL — the soft-delete convention.
PostgreSQL Tutorial — SELECT, joins & aggregates
Example-driven pages for each clause. Then the official docs' Queries chapter for the precise semantics.
→ postgresqltutorial.com (SELECT / joins / GROUP BY)
→ postgresql.org/docs — Queries
Check yourself (from memory)
Q1. An INNER JOIN returns rows where…
Q2. GROUP BY is used together with…
HAVING filters groups; WHERE filters rows first.
Q3. Why do our reads always end with deleted_at IS NULL?
SELECT subject FROM emails WHERE email_id = ANY($1) AND
deleted_at IS NULL; — SELECT subject picks the column;
FROM emails the table; WHERE email_id = ANY($1) filters to the
id list passed as one array parameter (never string-concatenated); AND deleted_at
IS NULL excludes soft-deleted rows. To add recipients:
JOIN email_recipients r ON r.email_id = e.email_id. To count per status:
GROUP BY status with count(*).WITH … AS), or window functions — all of which our
conversationmgmt queries use? Ask me.
1. PostgreSQL — Queries; repo rule: values via $N params (.claude/rules/security.md).