673 lines
28 KiB
Text
673 lines
28 KiB
Text
|
---
|
|||
|
title: "Select Star SQL"
|
|||
|
subtitle: "SQL Introduction follow-along"
|
|||
|
description: "SQL Introduction follow-along"
|
|||
|
author:
|
|||
|
- Marty Oehme
|
|||
|
date: today
|
|||
|
pubDate: "2024-01-24T09:10:23+01:00"
|
|||
|
weight: 10
|
|||
|
toc: true
|
|||
|
tags:
|
|||
|
- sql
|
|||
|
---
|
|||
|
|
|||
|
## Quick Introduction
|
|||
|
|
|||
|
I have recently been brushing up on my SQL skills,
|
|||
|
because I firmly believe they are one of the most fundamental knowledge areas if you do anything to do with data.
|
|||
|
|
|||
|
The following content will follow along with the excellent [Star Select SQL](https://selectstarsql.com/) introductory resource to SQL.
|
|||
|
We will go through all four chapters, and follow most of the examples and challenges given.
|
|||
|
Sometimes, however, we will swerve a little and approach things slightly differently,
|
|||
|
or with a different query twist -
|
|||
|
just because my mind obviously works differently than that of Zi Chong Kao who wrote the book.
|
|||
|
|
|||
|
In general, there is slightly more of a focus on the actual SQL statements,
|
|||
|
the knowledge imparted and perhaps a more explicit wording regarding notes and limits in the queries.
|
|||
|
|
|||
|
Less of a focus is given to the meaning of the data under analysis,
|
|||
|
though we of course still use the same dataset, and attempt to draw the same conclusions from similar queries.
|
|||
|
In other words:
|
|||
|
If you want to learn (and be excited by the possibilities and challenges of) SQL, go to [Select Star SQL](https://selectstarsql.com/).
|
|||
|
If you have already done it and want a refresher, this page will give you a quick overview separated by topical chapter.
|
|||
|
Or use it as inspiration to read-along like I did.
|
|||
|
|
|||
|
This document uses quarto under the hood and we will experiment a little with the R SQL interactions along the way.
|
|||
|
It is also my first attempt to really integrate quarto publications into this blog,
|
|||
|
in an attempt to ease the process for the future.
|
|||
|
For now, we just load the database (as an SQLite file) and show which table we have,
|
|||
|
using some R packages:
|
|||
|
|
|||
|
```{r}
|
|||
|
suppressPackageStartupMessages(library(tidyverse)) # suppressing conflict warnings in outputs
|
|||
|
library(DBI)
|
|||
|
library(RSQLite)
|
|||
|
con <- dbConnect(SQLite(), "data/tx_deathrow_full.db")
|
|||
|
as.data.frame(dbListTables(con))
|
|||
|
```
|
|||
|
|
|||
|
That seems reasonable! We have a single table in the dataset, called `deathrow`.
|
|||
|
|
|||
|
This is perhaps a good moment to mention that the tables and colums do *not* have the exact same names as they do on the website's interactive follow-along boxes.
|
|||
|
Instead, they have the column names that the author gave the columns for the full data set that he made available as a csv download, I have not changed any of them.
|
|||
|
|
|||
|
<details>
|
|||
|
<summary>A list of all column headers in the `deathrow` table.</summary>
|
|||
|
0|Execution|TEXT\
|
|||
|
1|Date of Birth|TEXT\
|
|||
|
2|Date of Offence|TEXT\
|
|||
|
3|Highest Education Level|TEXT\
|
|||
|
4|Last Name|TEXT\
|
|||
|
5|First Name|TEXT\
|
|||
|
6|TDCJ Number|TEXT\
|
|||
|
7|Age at Execution|TEXT\
|
|||
|
8|Date Received|TEXT\
|
|||
|
9|Execution Date|TEXT\
|
|||
|
10|Race|TEXT\
|
|||
|
11|County|TEXT\
|
|||
|
12|Eye Color|TEXT\
|
|||
|
13|Weight|TEXT\
|
|||
|
14|Height|TEXT\
|
|||
|
15|Native County|TEXT\
|
|||
|
16|Native State|TEXT\
|
|||
|
17|Last Statement|TEXT\
|
|||
|
</details>
|
|||
|
|
|||
|
Perhaps, for a future version it might be an interesting data cleaning experiment to actually change them to the same names before following the book.
|
|||
|
|
|||
|
## Chapter 1 - Individual operations {#sec-individual}
|
|||
|
|
|||
|
For now, to test the database connection, we simply print three inmate names:
|
|||
|
We do so by doing a `SELECT` for just three rows from the overall table.
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
-- SELECT * FROM deathrow LIMIT 3;
|
|||
|
SELECT "First Name", "Last Name" FROM deathrow LIMIT 3;
|
|||
|
```
|
|||
|
|
|||
|
Everything seems to be working smoothly, and we can even directly create `sql` code chunks which connect to our database.
|
|||
|
Neat!
|
|||
|
|
|||
|
The star indeed selects 'everything' (somewhat like globbing) which means we `SELECT` *everything* from out deathrow table, but limit the number of rows to three.
|
|||
|
In other words, `SELECT` is more oriented towards columns, with `LIMIT` (and later e.g. `WHERE`) instead filtering out rows.
|
|||
|
|
|||
|
The 'star' selection is only a theory in this document, unfortunately,
|
|||
|
with `quarto` (and `knitr`) as our rendering engines.
|
|||
|
Doing this selection here would just end in a word-spaghetti since we just have *too many columns* to nicely display.
|
|||
|
Instead, we limit ourselves to just a couple.
|
|||
|
You can see the 'star' selection as a comment underneath the one we did, however.
|
|||
|
|
|||
|
For now, we `SELECT` from out table.
|
|||
|
We do not have to.
|
|||
|
Selections at their most basic simply return whatever returns a boolean 'truthy' value for the columns passed in,
|
|||
|
even if we do not `SELECT` `FROM` a specific table:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT 50 + 2, 51 / 2, 51 / 2.0;
|
|||
|
```
|
|||
|
|
|||
|
This example also reflects the float/integer differences in SQL:
|
|||
|
If you only work with integers, the result will be an integer.
|
|||
|
To work with floating point numbers at least one of the involved numbers has to be a float.
|
|||
|
Often this is accomplished by just multiplying with `* 1.0` at some point in the query.
|
|||
|
|
|||
|
Having filtered on *columns* above with `SELECT`, let us now filter on *rows* with the `WHERE` block:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "Last Name", "First Name"
|
|||
|
FROM deathrow
|
|||
|
WHERE "Age at Execution" <= 25;
|
|||
|
```
|
|||
|
|
|||
|
In this case, we filter for all executions aged 25 and under (only very few due to the lengthy process).
|
|||
|
`WHERE` blocks take an expression which results in a boolean truth value (in this case 'is it smaller than or equal to?'),
|
|||
|
and for every row that the result is true will include them for the rest of the query.
|
|||
|
|
|||
|
This bit is also important, as `WHERE` operations will generally run before other operations such as `SELECT` or aggregations, as we will see in the next chapter.
|
|||
|
|
|||
|
As a last bit to notice, the order of `SELECT` conditions also matters:
|
|||
|
it is the order the rows will appear as in the resulting table.
|
|||
|
Here, we switched first and last names compared to the last table query.
|
|||
|
|
|||
|
While numeric comparisons are one thing, we can of course filter on text as well.
|
|||
|
A single equals sign generally accomplishes conditional comparison:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "First Name", "Last Name"
|
|||
|
FROM deathrow
|
|||
|
WHERE "Last Name" = "Jones";
|
|||
|
```
|
|||
|
|
|||
|
Of course, string comparison needs some leeway to account for small differences in the thing to be searched for.
|
|||
|
For example, names could have additions ('Sr.', 'Jr.') or enumeration ('John II') or even simple misspellings.
|
|||
|
We can use `LIKE` to help with that for string comparisons:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "First Name", "Last Name", "Execution"
|
|||
|
FROM deathrow
|
|||
|
WHERE "First Name" LIKE 'Raymon_'
|
|||
|
AND "Last Name" LIKE '%Landry%';
|
|||
|
```
|
|||
|
|
|||
|
It allows a few wildcards in your queries to accomplish this:
|
|||
|
`_` matches exactly one character,
|
|||
|
while `%` matches any amount of characters.
|
|||
|
Both only operate in the place the are put, so that `%Landry` and `%Landy%` can be different comparisons.
|
|||
|
|
|||
|
Above you can also see that we can logically concatenate query parts.
|
|||
|
The precedence order goes `NOT`, then `AND`, then `OR`, so that the following:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT 0 AND 0 OR 1;
|
|||
|
```
|
|||
|
|
|||
|
Returns `1`.
|
|||
|
It reads '0 and 0', which results in a 0; and *then* it reads '0 or 1' which ultimately results in a 1.
|
|||
|
To re-organize them into the precedence we require, simply use parentheses:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT 0 AND (0 OR 1);
|
|||
|
```
|
|||
|
|
|||
|
The parenthesized `OR` clause is now looked at before the `AND` clause, resulting in a 0.
|
|||
|
|
|||
|
As a cap-stone for Chapter 1,
|
|||
|
and to show that we do *not* need the column we filter on (with `WHERE`) as a `SELECT`ed one in the final table,
|
|||
|
we will select a specific statement:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "Last Statement"
|
|||
|
FROM deathrow
|
|||
|
WHERE "Last Name" = 'Beazley';
|
|||
|
```
|
|||
|
|
|||
|
## Chapter 2 - Aggregations {#sec-aggregation}
|
|||
|
|
|||
|
We can use aggregators (such as `COUNT`, `MEAN` or `MEDIAN`) to consolidate information from *multiple* rows of input.
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT COUNT("Last Statement") FROM deathrow WHERE "Last Statement" != '';
|
|||
|
```
|
|||
|
|
|||
|
Here, we diverge slightly from the book:
|
|||
|
Whereas for them, NULLs are used where no statement exists, this is not so for our dataset.
|
|||
|
|
|||
|
Since we did no cleaning after importing from csv, empty statements will be imported as an empty string and not a `NULL` object.
|
|||
|
Since count uses `NULL` objects (as a 'non-count' if you want) we additionally have to select all those rows where the statements are not empty strings.
|
|||
|
|
|||
|
We can count the overall number of entries (i.e. the denominator for later seeing which share of inmates proclaimed innocence) by `COUNT`ing a column which we know to have a value for each entry:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT COUNT("ex_age") FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
Or, even better as the book shows, use `COUNT(*)` to count the value in all columns. Since we can't have completely `NULL` rows (the row would just not exist), we will definitely get an overall count back this way:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT COUNT(*) FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
Now, let's see how we can combine this with conditional `CASE WHEN` statements.
|
|||
|
|
|||
|
Let's find all inmates from 'Harris' or 'Bexar' country:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
SUM(CASE WHEN "County"='Harris' THEN 1 ELSE 0 END),
|
|||
|
SUM(CASE WHEN "County"='Bexar' THEN 1 ELSE 0 END)
|
|||
|
FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
We can try to find the number of inmates that where over 50 years old at their point of execution:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT COUNT(*) FROM deathrow WHERE "Age at Execution" > 50;
|
|||
|
```
|
|||
|
|
|||
|
You can see that the `WHERE` block selects *before* we run aggregations.
|
|||
|
That is useful since we first reduce the amount of entries we have to consider before further operations.
|
|||
|
|
|||
|
Now, we can practice working with conditions, counts and cases for the same goal:
|
|||
|
Finding all instances where inmates did *not* give a last statement.
|
|||
|
|
|||
|
First, we will do so again with a `WHERE` block:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT COUNT(*) FROM deathrow WHERE "Last Statement" = '';
|
|||
|
```
|
|||
|
|
|||
|
Then, we can do the same, but using a `COUNT` and `CASE WHEN` blocks:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
COUNT(CASE WHEN "Last Statement" = '' THEN 1 ELSE NULL END)
|
|||
|
FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
This is a little worse performance-wise.
|
|||
|
Whereas in the first attempt (using `WHERE`) we first filter the overall table down to relevant entries and then count them, here we go through the whole table to count each (or 'not-count' those we designate with `NULL`).
|
|||
|
|
|||
|
Lastly, if we had cleaned the data correctly before using it here (especially designated empty strings as `NULL` objects), we could use `COUNT` blocks only:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
COUNT(*) - COUNT("Last Statement")
|
|||
|
FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
You can see, however, that this results in `0` entries --- we count *all* entries both times since nothing is `NULL`.
|
|||
|
While somewhat contrived here, it should point to the fact that it is generally better to have clean `NULL`-designated data before working with it.
|
|||
|
|
|||
|
However, this way of counting would also be the least performant of the three, with all rows being aggregated with counts twice.
|
|||
|
|
|||
|
In other words, the exercise showed us three things:
|
|||
|
|
|||
|
- there are many ways to skin an SQL query
|
|||
|
- correct `NULL` designations can be important during cleaning
|
|||
|
- performant operations should generally filter before aggregating
|
|||
|
|
|||
|
There are more aggregation functions, such as `MIN`, `MAX`, `AVG`.[^docs]
|
|||
|
|
|||
|
[^docs]: The book recommends documentation as [SQLITE](http://sqlite.org), [W3 Schools](https://www.w3schools.com/sql/default.asp) and, of course, Stack overflow.
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
MIN("Age at Execution"),
|
|||
|
MAX("Age at Execution"),
|
|||
|
AVG("Age at Execution")
|
|||
|
FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
We can also combine aggregations, running one on the results of another (just like combining function outputs in e.g. python).
|
|||
|
Here is an example calculating the average length of statements, for cases where a statement exists using the `LENGTH` aggregation:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
AVG(LENGTH("Last Statement"))
|
|||
|
FROM deathrow
|
|||
|
WHERE "Last Statement" != '';
|
|||
|
```
|
|||
|
|
|||
|
Another aggregation is `DISTINCT` which works somewhat like the program `uniq` on Unix systems:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT DISTINCT("County") FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
It presents all the options (or 'categories' if you think of your data as categorical) that are represented in a column, or the output of another aggregation.
|
|||
|
|
|||
|
On its face it is less of an *aggregation* function as the book remarks, since it does not output "a single number".
|
|||
|
But since it 'aggregates' the contents of multiple rows into its output I would still very much classify it as such.
|
|||
|
|
|||
|
Finally, let's look at what happens with 'misshapen' queries:
|
|||
|
|
|||
|
<!-- ::: {#lst-strange-query} -->
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "First Name", COUNT(*) FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
<!-- A strange query -->
|
|||
|
|
|||
|
<!-- ::: -->
|
|||
|
|
|||
|
Here, we make a query which is indeed very strange:
|
|||
|
The `COUNT` aggregation wants to output a single aggregated number, while the single column selection `"First Name"` wants to output each individual row.
|
|||
|
|
|||
|
What happens?
|
|||
|
The database goes easy on us and does not error out but uses the aggregation as guide that we only receive a single output back and picks an entry from the first names.
|
|||
|
|
|||
|
In the book, this is the last entry ('Charlie'), though it does warn that not all databases return the same.
|
|||
|
This is reflected in the SQLite query here in fact returning the *first* entry ('Christopher Anthony') instead.
|
|||
|
|
|||
|
The lesson being to *not* rely on unclear operations like this but being explicit if, say, we want to indeed grab the last entry of something:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "First Name" FROM deathrow ORDER BY ROWID DESC LIMIT 1;
|
|||
|
```
|
|||
|
|
|||
|
Since SQLite does not come with a convenient `LAST` aggregator (some other databases do),
|
|||
|
we need to work around it by reversing the order of the table based on its `ROWID` (which increase for each entry).
|
|||
|
Thus, the highest `ROWID` is the last entry.
|
|||
|
Having reversed it, we can limit the output to the very last one to arrive back at 'Charlie'.[^SOlast]
|
|||
|
|
|||
|
[^SOlast]: This is cribbed from the very nice tips on grabbing the last SQLite entry on [this Stack Overflow](https://stackoverflow.com/q/24494182) question.
|
|||
|
|
|||
|
However, this operation (as far as I know for now) is now not compatible anymore with *also* aggregating on a row count like we did above.
|
|||
|
|
|||
|
So, for the final query in this aggregation chapter, let's see the share of inmates which insisted on their innocence even during the last statement:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
1.0 * COUNT(CASE WHEN "Last Statement" LIKE '%innocent%' THEN 1 ELSE NULL END) / (COUNT(*)) * 100
|
|||
|
FROM deathrow;
|
|||
|
```
|
|||
|
|
|||
|
We can see that over 5% of the people proclaimed their innocence even during their final statement.
|
|||
|
|
|||
|
Of course, this method of simple string matching has some issues:
|
|||
|
If somebody uses other statements (the book mentions the example of 'not guilty') we have a lower bound of proclamations.
|
|||
|
We also do not know in what way people used the word --- what if they instead proclaimed *not* being innocent?
|
|||
|
We would rather have a higher bound than the true number in this case.
|
|||
|
|
|||
|
At the same time, we do not know the thoughts of the people not giving last statements at all.
|
|||
|
Perhaps, it would also make sense to compare only the number of people who did give a statement (but did not mention their innocence) with those who did, which would put us on the lower bound again.
|
|||
|
|
|||
|
We can see this behavior if we just show a sub-section of the statements:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "First Name", "Last Statement"
|
|||
|
FROM deathrow
|
|||
|
WHERE "Last Statement" LIKE '%innocent%'
|
|||
|
LIMIT 4;
|
|||
|
```
|
|||
|
|
|||
|
While Preston, Jonathan and Keith do protest their innocence to the warden, their loved ones or the world,
|
|||
|
Jeffrey instead mentions 'innocent kids'.
|
|||
|
Now, he could include himself in this category but we do not know for sure.
|
|||
|
|
|||
|
So, this concludes a chapter about *aggregating*:
|
|||
|
operating with functions on multiple rows in the dataset, allowing study of more system-level behavior in the data.
|
|||
|
|
|||
|
## Chapter 3 - Grouping and Nesting {#sec-grouping}
|
|||
|
|
|||
|
After dealing with individual rows (Ch 1) and aggregations (Ch 2), we will now do some data *organization* based on specific rows or columns -
|
|||
|
a sort of mish-mash between keeping multiple rows in the output like in the first case and doing operations on them like in the latter.
|
|||
|
|
|||
|
The chapter begins by looking at a visualization of the data, which shows a strong long right tail (or, right skew as I know it called).
|
|||
|
We ultimately will end the chapter by taking a look at the percentage breakdown of executions each county contributed to investigate this skew.
|
|||
|
|
|||
|
Grouping can be accomplished with the `GROUP BY` block:[^semicolon]
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"County",
|
|||
|
COUNT(*) AS county_executions
|
|||
|
FROM deathrow
|
|||
|
GROUP BY "County"
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
[^semicolon]: You can see that I have started putting the final semicolon on its own separate line.
|
|||
|
It is a technique I have seen being used by 'Alex the Analyst' on YouTube and which I am quite keen on replicating.
|
|||
|
For me, it serves two purposes:
|
|||
|
First, for some SQL dialects, the closing semicolon is actually required and this makes it harder to forget it.
|
|||
|
Second, it provides a visually striking *close* of the query for myself as well, which may be useful once getting into more dense queries.
|
|||
|
|
|||
|
This reminds a lot of the 'misshapen' query above, however, there is a key difference:
|
|||
|
Even when doing aggregations around it, the column(s) *being grouped on* is allowed to be a multi-output column (called 'grouping columns').
|
|||
|
|
|||
|
Let's first do another quick grouping query to see how it can work.
|
|||
|
We'll try to find the most common last names:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "Last Name", COUNT(*) AS count
|
|||
|
FROM deathrow
|
|||
|
GROUP BY "Last Name"
|
|||
|
ORDER BY "count" DESC
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
The code above also makes use of *aliasing*, with an `AS <new-name>` block with which you can provide an efficient short-hand or new name for `SELECT` outputs.
|
|||
|
I believe it also works for the outputs of e.g. `JOIN` operations.
|
|||
|
|
|||
|
Let's now have a breakdown of executions with and without a last statement by county:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"Last Statement" IS NOT '' AS has_last_statement,
|
|||
|
"County",
|
|||
|
COUNT(*)
|
|||
|
FROM deathrow
|
|||
|
GROUP BY "County", "has_last_statement"
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
The order in which you group by here matters!
|
|||
|
In this case, we first order by county and then statements -
|
|||
|
all 'Anderson' inmates appear first, then all 'Aransas', at some point the 'Bell' county cases, both those with and without statement, before 'Bexar' county, and so on.
|
|||
|
Had we the groupings the other way around,
|
|||
|
we would first have all `has_last_statement = 0` entries, from 'Anderson' county to 'Wood' county last, and then repeat the same for all counts of cases with statements.
|
|||
|
|
|||
|
Also, we can of course manually influence this order.
|
|||
|
Using the `ORDER BY` block we can choose a column with which to order, so regardless of the grouping sequence we can make sure to for example order on counties.
|
|||
|
We can of course also sort by different column altogether of course, such as the counts which would then require a naming alias.
|
|||
|
Using `ORDER BY "Column" DESC` we can reverse the sorting.
|
|||
|
Here is the same example from above implementing most of these ideas:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"Last Statement" IS NOT '' AS has_last_statement,
|
|||
|
"County",
|
|||
|
COUNT(*) AS number
|
|||
|
FROM deathrow
|
|||
|
GROUP BY "has_last_statement", "County"
|
|||
|
ORDER BY "number" DESC
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
We already know from @sec-aggregation that `WHERE` blocks will take place before any aggregation.
|
|||
|
The same is true for groupings - `WHERE` will always execute (and thus filter) before `GROUP BY` executes.
|
|||
|
|
|||
|
The following counts the number of inmates executed that were at least 50 for each county:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"County",
|
|||
|
COUNT(*) AS number
|
|||
|
FROM deathrow
|
|||
|
WHERE "Age at Execution" >= 50
|
|||
|
GROUP BY "County"
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
We do not select the age column for further consideration, but since `WHERE` runs before all other operations we also do not need it.
|
|||
|
But what if we want to filter on the *outputs* of grouping or aggregation functions?
|
|||
|
The `HAVING` block solves that.
|
|||
|
|
|||
|
The following shows the counties in which at least two inmates 50 or older were executed:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"County",
|
|||
|
COUNT(*) AS number
|
|||
|
FROM deathrow
|
|||
|
WHERE "Age at Execution" >= 50
|
|||
|
GROUP BY "County"
|
|||
|
HAVING "number" > 2
|
|||
|
ORDER BY "number" DESC
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
As one interesting fact for possibly more advanced queries:
|
|||
|
`GROUP BY` blocks do *not* need the columns on which they group to be in the `SELECT` block!
|
|||
|
Generally this does not make a lot of sense - when we group by county but do not see county then it just seems like fairly weird groupings,
|
|||
|
but there will invariably be situations where this knowledge is useful.
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "County"
|
|||
|
FROM deathrow
|
|||
|
GROUP BY "County"
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
This exactly mirrors the `SELECT DISTINCT` aggregation, but is accomplished with grouping instead.
|
|||
|
Many ways to skin a query again!
|
|||
|
|
|||
|
Now let's pivot a little and look at query nesting.
|
|||
|
Since we sometimes will want to run one query leading into another (e.g. to compute percentages),
|
|||
|
we have to have some way to integrate them with another.
|
|||
|
We do so through *nested queries*, demarcated with `(parantheses)` within another query.
|
|||
|
|
|||
|
Let's see how we utilize this to select the inmate with the longest last statement:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT "First Name", "Last Name"
|
|||
|
FROM deathrow
|
|||
|
WHERE LENGTH("Last Statement") =
|
|||
|
(
|
|||
|
SELECT MAX(LENGTH("Last Statement"))
|
|||
|
FROM deathrow
|
|||
|
)
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
It looks a little cumbersome but essentially we first filter on the row whose statement length is (exactly) the length of the longest statement,
|
|||
|
previously queried as a separate sub-query.
|
|||
|
|
|||
|
Why do need a nested query here?
|
|||
|
The book itself explains it most succinctly:
|
|||
|
|
|||
|
> nesting is necessary here because in the WHERE clause,
|
|||
|
as the computer is inspecting a row to decide if its last statement is the right length,
|
|||
|
it can’t look outside to figure out the maximum length across the entire dataset.
|
|||
|
We have to find the maximum length separately and feed it into the clause.
|
|||
|
|
|||
|
We will now attempt to do the same to find the percentage of all executions contributed by each county:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"County",
|
|||
|
100.0 * COUNT(*) / (
|
|||
|
SELECT COUNT(*)
|
|||
|
FROM deathrow
|
|||
|
) as percentage
|
|||
|
FROM deathrow
|
|||
|
GROUP BY "County"
|
|||
|
ORDER BY "percentage" DESC
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
It follows the same concept:
|
|||
|
We need to invoke a nested query because our original query is already fixated on a sub-group of all rows and we **can not get out of it within our query**.
|
|||
|
Instead, we invoke another query 'before' the original which still has access to all rows and create our own nested aggregation.
|
|||
|
The output of that then feeds into the original query.
|
|||
|
|
|||
|
Quite clever, a little cumbersome, and presumably the origin of quite a few headaches in writing elegant SQL queries.
|
|||
|
|
|||
|
## Chapter 4 - Joins and Dates {#sec-joining}
|
|||
|
|
|||
|
Before we look at joins, let's look at handling dates (as in, the data type) in SQL.
|
|||
|
While we have a couple of columns of reasonably formatted dates (ISO-8601), that doesn't mean we can automatically use them as such.
|
|||
|
To make use of such nice and clean data we should use operations that specifically [make use of dates](https://www.sqlite.org/lang_datefunc.html) for their calculations.
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
julianday('1993-08-10') - julianday('1989-07-07') as day_diff
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
The `julianday()` function will transform our ISO-compatible dates into timestamp floats on which we can operate like usual, in this case subtracting the latter from the former to get the period of time between them.
|
|||
|
Like the unix timestamp, the Julian day counts from a specific point in time as 0 continuously upwards,
|
|||
|
only that it counts from 4714 B.C.E. (not 1971) and per-day not per-second.
|
|||
|
Anything below a single day is fractional.
|
|||
|
Half a day's difference would thus be `0.5` difference, making it perhaps more useful to work with larger time differences.
|
|||
|
|
|||
|
Now we will join a table with itself, only shifted by a row.
|
|||
|
This will make it necessary to prepare the date for the 'other' table first,
|
|||
|
adding one to the column we are going to join on.
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"Execution" + 1 AS ex_number,
|
|||
|
"Execution Date" AS prev_ex_date
|
|||
|
FROM deathrow
|
|||
|
WHERE "Execution" < 553
|
|||
|
```
|
|||
|
|
|||
|
We could perhaps use `julianday` comparisons, but since we have access to the execution numbers and they are rolling we can instead use them like an ID and just shift it one up.
|
|||
|
|
|||
|
Now we want to put data from one row into *another* row and neither aggregations nor groups can help us out.
|
|||
|
Instead, to gain access to data from other rows (whether in the same or another table) we use the `JOIN` block.
|
|||
|
There is an `INNER JOIN` (the default), a `LEFT JOIN`, a `RIGHT JOIN` and an `OUTER JOIN` block.
|
|||
|
|
|||
|
**The different joins only differ in how they handle unmatched rows.**
|
|||
|
With an inner join, any unmatched rows are dropped completely (essentially intersection merge),
|
|||
|
with an outer join unmatched rows are preserved completely from both tables (union merge),
|
|||
|
with a left join unmatched rows from the *left* (i.e. `FROM XY`) table are preserved,
|
|||
|
with a right join unmatched rows from the *right* (`JOIN XY`) table.
|
|||
|
|
|||
|
This prepares our table to be used to join 'itself', adding those rows (shifted) together which we want.
|
|||
|
Of course, we do not have to shift in our selection already, and I find it more intuitive to do so in the value comparison.
|
|||
|
We end up with the following query:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
"Execution Date",
|
|||
|
prev_ex_date AS "Previous Execution",
|
|||
|
JULIANDAY("Execution Date") - JULIANDAY(prev_ex_date) AS "Difference in Days"
|
|||
|
FROM deathrow
|
|||
|
JOIN (
|
|||
|
SELECT
|
|||
|
"Execution" AS ex_number,
|
|||
|
"Execution Date" AS prev_ex_date
|
|||
|
FROM deathrow
|
|||
|
WHERE "Execution" < 553
|
|||
|
) AS previous
|
|||
|
ON deathrow."Execution" = previous.ex_number + 1
|
|||
|
ORDER BY "Difference in Days" DESC
|
|||
|
LIMIT 10
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
This shows us the top ten timeframes in which no executions occured.
|
|||
|
You can see we do the shifting in the `ON` block itself, leading to the more natural reading of
|
|||
|
'join the tables on execution number being the same as the previous execution number plus one'.
|
|||
|
For my brain this is more easily comprehensible as a row-shift.
|
|||
|
Otherwise, we only select the execution number
|
|||
|
(though we only need it for the shift operation and drop it in the outer selection)
|
|||
|
and the execution date which is the one important column we are looking for.
|
|||
|
|
|||
|
We also do not include Execution number 553 (the largest execution number) since there will be no newer execution to join it with in the dataset.
|
|||
|
The resulting table will not be different if we do not, however.
|
|||
|
Remember we are doing an `INNER JOIN`, which drops any non-matching rows by default.
|
|||
|
|
|||
|
Such a 'self join' is a common technique to **grab information from other rows** from the same table.
|
|||
|
This is already quite an advanced query!
|
|||
|
|
|||
|
The book plots a graph here, which I will not replicate for the moment.
|
|||
|
However, it shows that roughly to pre-1993 there was a lower overall execution count,
|
|||
|
with two clearly visible larger hiatuses afterwards.
|
|||
|
|
|||
|
Let's focus on those two hiatuses and limit the data to not show pre-1993 executions.
|
|||
|
As a last thing, let's make it a little more elegant by making the original ('previous') table query way simpler:
|
|||
|
|
|||
|
```{sql connection=con}
|
|||
|
SELECT
|
|||
|
previous."Execution Date" AS "Beginning of period",
|
|||
|
deathrow."Execution Date" AS "End of period",
|
|||
|
JULIANDAY(deathrow."Execution Date") - JULIANDAY(previous."Execution Date") AS "Difference in Days"
|
|||
|
FROM deathrow
|
|||
|
JOIN deathrow AS previous
|
|||
|
ON deathrow."Execution" = previous."Execution" + 1
|
|||
|
WHERE DATE(deathrow."Execution Date") > DATE('1994-01-01')
|
|||
|
ORDER BY "Difference in Days" DESC
|
|||
|
LIMIT 10
|
|||
|
;
|
|||
|
```
|
|||
|
|
|||
|
We can also see much more clearly what the book is talking about with big stays of execution occuring in 1996-1997, as well as 2007-2008.
|
|||
|
|
|||
|
A little more wordy per line but overall a lot more elegant.
|
|||
|
And mostly enabled due to putting the row 'shift' into the `ON` block itself.
|
|||
|
However, the importance of good name-aliasing and `JOIN ON` blocks are definitely highlighted.
|
|||
|
We are now equipped to grab data from multiple rows, multiple tables and rearrange them as we see necessary.
|
|||
|
|
|||
|
## Quick Conclusion
|
|||
|
|
|||
|
So, this should already give a rough mental model for the *kinds* of operations to be done with SQL.
|
|||
|
|
|||
|
We can operate on the contents of a single row,
|
|||
|
we can aggregate the contents of many rows resulting in a single-row output,
|
|||
|
we can group by columns which allows us to aggregate into multiple rows,
|
|||
|
and we can work with the contents of *other* rows by joining tables.
|
|||
|
|
|||
|
We have learned to *query* data but not to create or manipulate date,
|
|||
|
i.e. working with side-effects.
|
|||
|
We also have not learned about the concepts of `window` functions or common table expressions.
|
|||
|
|
|||
|
These are additional operations to get to know,
|
|||
|
but of course it is also important to get an overall broader view onto the concepts and mental mapping of SQL itself.
|
|||
|
The book closes with a call-to-challenge, with an additional dataset to knock your teeth on.
|