Justin Jaffray

blog notes

JOIN: The Ultimate Projection

13 Jun 2022

Or: the core idea of how database query planners decorrelate subqueries.

Joins

In a relational database, to compute the join of \(R\) and \(S\) on \(p\), for each row \(r\) in \(R\), find all the rows \(s\) in \(S\) where \(p(r, s)\) is true, and emit the concatenation of \(r\) and \(s\). Readers of this blog might note this is a different definition than we’ve used in the past, but this one is well-suited to the way we’ll be using joins today.

Joins are an important operation when working with normalized data. If I have a table of users along with region IDs, plus a table which maps those IDs to the names of the regions:

> SELECT u_name, u_region_id FROM users;
  u_name | u_region_id
---------+--------------
  Bilal  |           1
  Irfan  |           2
  Jordan |           3
  Justin |           1
  Paul   |           3
> SELECT r_id, r_name FROM regions;
  r_id |   r_name
-------+--------------
     1 | Mississauga
     2 | Toronto
     3 | New York

To match people with the name of their region, we can join the two tables on having matching region ids:

> SELECT u_name, r_name FROM users JOIN regions ON u_region_id = r_id;
  u_name |   r_name
---------+--------------
  Bilal  | Mississauga
  Irfan  | Toronto
  Jordan | New York
  Justin | Mississauga
  Paul   | New York

From our definition, this is “for each user, find all regions such that u_region_id = r_id” (in this particular case, there’s only one, since r_id is a key for the regions table).

Projections

A projection is an operation on a relation which either trims away or adds columns based on existing ones. In SQL, we can compute a new expression derived from other columns by simply writing out that expression in our SELECT list:

> SELECT a FROM ab;
  a
-----
  1
  2
  3
> SELECT a, a*a AS aa FROM ab;
  a | aa
----+-----
  1 |  1
  2 |  4
  3 |  9

This adds a new column aa which is the square of the a column.

Correlated Subqueries

SQL allows a very special kind of projection: an entire query.

Rather than writing out the join explicitly above, one might prefer to think of that computation how they would in a normal programming language: for each user, I want to look up their region:

> SELECT u_name, (SELECT r_name FROM regions WHERE r_id = u_region_id)
    FROM users;
  u_name |   r_name
---------+--------------
  Bilal  | Mississauga
  Irfan  | Toronto
  Jordan | New York
  Justin | Mississauga
  Paul   | New York

This is called a subquery. Since the subquery refers to columns from its outer scope (that is: it has free variables) it’s called a correlated subquery.

Such queries are notoriously tricky to implement in relational databases. This is unfortunate, as they’re often very useful. In particular, it’s often much easier to construct queries of this form programmatically than it is to explicitly construct a join programmatically.

Remember, databases like joins a lot. Not just because they’ve been optimized very aggressively, but the symmetry of joins gives the query optimizer a lot of freedom to pick the best possible way to execute them. The subquery form of the query only really implies one implementation: run the subquery for each row. Can we do better?

While this thing is not obviously a join, we might suspect it’s pretty close, given that it has the same output as our join from before. Which raises the question: can we teach a query planner to transform a correlated subquery like this into a join?

Representing Functions

In high school you might have learned that a good way to represent a function is with a table of values, or function table. We can represent the function \[ f(x) = x^2 \]

with the (infinite) table

\(x\) \(x^2\)
-1 1
0 0
1 1
2 4
3 9

Where the input to the function is the left column, and the corresponding output is the right column. The thing that makes this a function and not merely a relation is that it passes the “vertical line test:” no value occurs in the left side of the table more than once.

However, it’s also a relation, so we can represent some finite subset of it in a relational database:

> CREATE TABLE squares AS
    (SELECT x, x*x AS xx FROM generate_series(-1000, 1000) x);
> SELECT * FROM squares WHERE x > -10 AND x < 10;
  x  | xx
-----+-----
  -9 | 81
  -8 | 64
  -7 | 49
  -6 | 36
  -5 | 25
  -4 | 16
  -3 |  9
  -2 |  4
  -1 |  1
   0 |  0
   1 |  1
   2 |  4
   3 |  9
   4 | 16
   5 | 25
   6 | 36
   7 | 49
   8 | 64
   9 | 81

These are cool for us because they give us a relational way of thinking about applying a function: to find \(f(x)\) you just find \(x\) in the left column and see what the value of \(f(x)\) in the right column is.

Projections are Joins

Recall our earlier query to project the square of a number:

> SELECT a, a*a AS aa FROM ab;
  a | aa
----+-----
  1 |  1
  2 |  4
  3 |  9

One way to think about joins is that they “look stuff up” in a relation. Rather than compute a*a explicitly, we could also just look it up in squares:

> SELECT a, xx AS aa FROM ab JOIN squares ON a = x;
  a | aa
----+-----
  1 |  1
  2 |  4
  3 |  9

Since we have a function table (squares), we can always do this rewrite:

SELECT a, a*a AS aa FROM ab;

\[ \rightarrow \]

SELECT a, xx AS aa FROM ab JOIN squares ON a = x;

In general, if we are trying to project f(a_1, ..., a_n) and we have a function table for f with columns x_1, ..., x_n, o (where the xs are the inputs to the function and o is the output), the following transformation is valid:

SELECT ..., f(a_1, ..., a_n), ... FROM a

\[ \rightarrow \]

SELECT ..., o, ... FROM
    a JOIN f ON a_1 = x_1 AND ... AND a_n = x_n

Decorrelation

Decorrelation is the act of taking a correlated subquery and turning it into a flat join.

Recall our correlated subquery from earlier:

SELECT u_name, (SELECT r_name FROM regions WHERE r_id = u_region_id)
    FROM users;

The projection

(SELECT r_name FROM regions WHERE r_id = u_region_id)

has one free variable: the column u_region_id, so let’s pull this out into a function:

f(u_region_id) = (SELECT r_name FROM regions WHERE r_id = u_region_id)

Now our query is

SELECT u_name, f(u_region_id) FROM users;

Now, do we have a function table for f? Yes: it’s just regions! So we can do our rewrite:

SELECT u_name, r_name FROM users JOIN regions ON u_region_id = r_id;

Which gives us a completely decorrelated query that our database is free to evaluate however it sees fit.

Conclusion

This was a very simplified example. There are a handful of caveats:

This is the core of the idea though, and I would argue, the conceptual “hard part.” Once you understand this, you more-or-less get the major idea of how decorrelation works.

In a follow up I’ll actually get into some details about we can do this for more complex subqueries. Here we could kind of just spot that regions was a valid function table, but that approach doesn’t scale and isn’t obvious how to automate.

References