Can We Read the Query in the Order We Explain the Job?
Use common table expressions to name meaningful query stages while keeping grain, scope and execution assumptions explicit.
13 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
Names Made Intermediate Relations Easier to Review
BOTIE
This is a language feature for expressing dependencies. Naming closed_jobs does not permanently create a table or promise a particular execution strategy. Its value for our report is that the next stage clearly receives completed work rather than every job in the system.
Sources for this history
Filter the Jobs, Then Add Their Minutes
Botie needs pumps whose completed maintenance has consumed more than fifty minutes. Open jobs contain estimates that should not enter this report. A long nested query can make it difficult to see whether those estimates were excluded before the total was calculated.
We can give the qualifying jobs a name, then give the per-pump totals another name. Each stage describes a relation we can inspect.
The records are invented maintenance examples. The workshop exposes record operations; the SQL worksheet executes a real query in an isolated SQLite database.
Step 1 of 1
Follow the report’s grain through its stages
The source has one row per job. The aggregation changes that meaning.
- 1
jobs
One row per recorded job - 2
closed_jobs
Only completed job rows - 3
totals
One row per asset with completed work - 4
report
Assets above the minute threshold
Each name should describe one relational step
Each component exposes the rows supplied to the next stage.
Keep each stage’s rows visible
Filter closed jobs, group their minutes by asset, then select totals exceeding the budget.
BOTIE
Asset IDs and completed minutes
[
{
"asset_id": "P7",
"total_minutes": 55
}
]Follow the operations
1. Recorded jobs
[ { "asset_id": "P7", "status": "closed", "minutes": 20 }, { "asset_id": "P7", "status": "closed", "minutes": 35 }, { "asset_id": "P7", "status": "open", "minutes": 200 }, { "asset_id": "P8", "status": "closed", "minutes": 40 } ]2. Set a text value
closed
3. Keep rows passing a comparison
[ { "asset_id": "P7", "status": "closed", "minutes": 20 }, { "asset_id": "P7", "status": "closed", "minutes": 35 }, { "asset_id": "P8", "status": "closed", "minutes": 40 } ]4. Summarise each group
[ { "asset_id": "P7", "total_minutes": 55 }, { "asset_id": "P8", "total_minutes": 40 } ]5. Completed-minute threshold
50
6. Keep rows passing a comparison
[ { "asset_id": "P7", "total_minutes": 55 } ]7. Order rows by named fields
[ { "asset_id": "P7", "total_minutes": 55 } ]8. Result
[ { "asset_id": "P7", "total_minutes": 55 } ]
CTE names and query-plan choices are taught in SQL; the graph shows their logical record transformations.
1. Name a useful intermediate result
WITH closed_jobs AS (...) places a name before the main query. Inside the parentheses is a SELECT describing the relation. Later stages and the final SELECT can refer to that name during this statement.
Start by selecting from closed_jobs directly. If open estimates remain, repair that boundary before adding aggregation.
A CTE is scoped to its statement. The next separate statement cannot assume that name still exists.
Inspect only the completed jobs
The open P7 estimate is excluded before any total is made.
| job_id | asset_id | status | minutes |
|---|---|---|---|
| 1 | P7 | closed | 20 |
| 2 | P7 | closed | 35 |
| 3 | P7 | open | 200 |
| 4 | P8 | closed | 40 |
| 5 | P9 | closed | NULL |
Database setup for this example
CREATE TABLE jobs(job_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, status TEXT NOT NULL, minutes INTEGER);
INSERT INTO jobs VALUES (1,'P7','closed',20),(2,'P7','closed',35),(3,'P7','open',200),(4,'P8','closed',40),(5,'P9','closed',NULL);WITH closed_jobs AS (
SELECT job_id, asset_id, minutes FROM jobs WHERE status='closed'
)
SELECT job_id, asset_id, minutes FROM closed_jobs ORDER BY job_id;| job_id | asset_id | minutes |
|---|---|---|
| 1 | P7 | 20 |
| 2 | P7 | 35 |
| 4 | P8 | 40 |
| 5 | P9 | NULL |
The NULL duration remains unknown. The filter changes which jobs qualify, not the meaning of their duration cells.
2. Let the next name depend on the earlier one
Separate CTE definitions with commas. The totals stage can read closed_jobs and group by asset_id. Its output is now one row per asset with at least one qualifying job.
Give the aggregate a useful column alias such as total_minutes. That name lets the final filter describe the question without repeating the aggregate expression.
The same statement can contain several named stages without creating permanent objects.
Build the report from two named relations
Inspect the source filter, then the changed grain, then the final threshold.
| job_id | asset_id | status | minutes |
|---|---|---|---|
| 1 | P7 | closed | 20 |
| 2 | P7 | closed | 35 |
| 3 | P7 | open | 200 |
| 4 | P8 | closed | 40 |
| 5 | P9 | closed | NULL |
Database setup for this example
CREATE TABLE jobs(job_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, status TEXT NOT NULL, minutes INTEGER);
INSERT INTO jobs VALUES (1,'P7','closed',20),(2,'P7','closed',35),(3,'P7','open',200),(4,'P8','closed',40),(5,'P9','closed',NULL);WITH closed_jobs AS (
SELECT asset_id, minutes FROM jobs WHERE status='closed'
), totals AS (
SELECT asset_id, SUM(minutes) AS total_minutes
FROM closed_jobs GROUP BY asset_id
)
SELECT asset_id, total_minutes FROM totals
WHERE total_minutes > 50
ORDER BY asset_id;| asset_id | total_minutes |
|---|---|
| P7 | 55 |
The large open estimate never enters P7’s completed total. P9’s unknown total does not pass the numeric threshold.
3. Use the names to explain the row meaning
At closed_jobs, job_id would still identify a row if we retained it. At totals, several job IDs have contributed to one asset row. Joining totals to another job-level table could multiply that summary again.
Write down the grain beside each stage when reviewing a query. A name such as data2 does little to help another person notice that the row meaning changed.
CTEs make a grain change visible; they do not prevent an incorrect later join.
Check yourself
Questions on Parts 1 to 3
Question 1 of 2
Which rows should enter the SUM?
Use the information below.
P7 has closed jobs lasting 20 and 35 minutes and an open estimate of 200 minutes. The report sums closed work only.4. Keep filters on the side of aggregation they belong to
The status condition describes individual jobs, so it belongs before their durations are added. The total threshold describes grouped assets, so it follows aggregation.
A direct GROUP BY with HAVING could express this same report. The named version is useful when we want to inspect or extend the intermediate relations. It is not automatically more correct or faster.
Compare the results of alternative forms on the same changed fixtures before deciding whether a rewrite preserves meaning.
5. Do not confuse naming with storing
An ordinary CTE is a query expression. Depending on the engine and the query, it may be folded into surrounding work or materialised as an intermediate result. Referencing its name twice is not a portable promise that expensive work runs exactly once.
SQLite documents MATERIALIZED and NOT MATERIALIZED hints, but these affect planning rather than changing the logical result. Begin without hints. Use the query plan and measurements before trying to force a strategy.
When a result must persist across statements, that is a different design involving a table or view.
6. Test the stage that a new record should enter
Insert a large open job and predict that the completed report stays unchanged. Close that same job and predict that its asset can now pass the threshold.
The workshop keeps filtering, grouping and the final threshold on separate components. Inspect the output of the first component when the wrong jobs enter the total rather than trying to repair the final number.
The useful structure follows the question’s dependencies. Additional CTEs are helpful when they name a real intermediate meaning.
Check yourself
Questions on Parts 1 to 6
Question 1 of 2
What changed from the job source table?
Use the information below.
A CTE totals contains one row per asset after SUM(minutes) GROUP BY asset_id.Try it with Botie
Build with Botie
Write and run SQL