Better SQL JOINs

46 points by JoelJacobson ↗ HN
I think foreign keys could be valuable to improve how we write SQL joins, in the special but common case when joining on columns that exactly match a foreign key.

The idea is to add a new ternary operator, which would be allowed only in the FROM clause.

It would take three operands:

1) referencing_table_alias 2) foreign_key_constraint_name 3) referenced_table_alias

POSSIBLE BENEFITS

* Eliminate risk of joining on the wrong columns Although probably an uncommon class of bugs, a join can be made on the wrong columns, which could go undetected if the desired row is included by coincidence, such as if the test environment might only contain a single row in some table, and the join condition happened to be always true.

* Conciser syntax In a traditional join, you have to explicitly state all columns for the referencing and referenced table. This is somewhat addressed by the USING join form, but USING has other drawbacks, why I tend to avoid it except for one-off queries. When having to use fully-qualified table aliases, that adds even further to the verboseness.

* Makes abnormal joins stand out If joining on something else than foreign key columns, or some inequality expression, such joins will continue to be written in the traditional way, and will therefore stand out and be more visible, if all other foreign key-based joins are written using the new syntax. When reading SQL queries, I think this would be a great improvement, since the boring normal joins on foreign keys could be given less attention, and focus could instead be made on making sure you understand the more complex joins.

SYNTAX

Syntax is hard, but here is a proposal to start the discussion:

    from_item join_type from_item WITH [referencing_table_alias]->[foreign_key_constraint_name] = [referenced_table_alias] [ AS join_using_alias ]
EXAMPLE

To experiment with the idea, I wanted to find some real-world queries written by others, to see how such SQL queries would look like, using traditional joins vs foreign key joins.

I came up with the idea of searching Github for "LEFT JOIN", since just searching for "JOIN" would match a lot of non-SQL code as well. Here is one of the first examples I found, a query below from the Grafana project [1] [1] https://github.com/grafana/grafana/blob/main/pkg/services/accesscontrol/database/resource_permissions.go

    SELECT
    p.*,
    ? AS resource_id,
    ur.user_id AS user_id,
    u.login AS user_login,
    u.email AS user_email,
    tr.team_id AS team_id,
    t.name AS team,
    t.email AS team_email,
    r.name as role_name
    FROM permission p
    LEFT JOIN role r ON p.role_id = r.id
    LEFT JOIN team_role tr ON r.id = tr.role_id
    LEFT JOIN team t ON tr.team_id = t.id
    LEFT JOIN user_role ur ON r.id = ur.role_id
    LEFT JOIN user u ON ur.user_id = u.id
    WHERE p.id = ?
Here is how the FROM clause could be rewritten:

    FROM permission p
    LEFT JOIN role r WITH p->permission_role_id_fkey = r
    LEFT JOIN team_role tr WITH tr->team_role_role_id_fkey = r
    LEFT JOIN team t WITH tr->team_role_team_id_fkey = t
    LEFT JOIN user_role ur WITH ur->user_role_role_id_fkey = r
    LEFT JOIN "user" u WITH ur->user_role_user_id_fkey = u
    WHERE p.id = 1;
In PostgreSQL, the foreign keys could also be given shorter names, since they only need to be unique per table and not per namespace. I think a nice convention is to give the foreign keys the same name as the referenced table, except if the same table is referenced multiple times or is self-referenced.

Rewriting our example, using such naming convention for the foreign keys:

    FROM permission p
    LEFT JOIN role r WITH p->role = r
    LEFT JOIN team_role tr WITH tr->role = r
    LEFT JOIN team t WITH tr->team = t
    LEFT JOIN user_role ur WITH ur->role = r
    LEFT JOIN "user" u WITH ur->user = u
    WHERE p.id = 1;

43 comments

[ 4.0 ms ] story [ 107 ms ] thread
Related — Postgres (other RDBMS's too, probably) has JOIN b USING (column, ...) and NATURAL JOIN. IIRC, both fail when there's ambiguity in column names.
I'm using PG 20 years, never used those. Observed code reviews where it was frowned upon. Precisely because of the ambiguity issue, possible future footgun.
I do not use SQL in anger on a regular basis at my day job. So I'm no expert. But it seems to me that the footgun is still there, it just fails slowly instead of immediately. The "fail immediately on ambiguity" footgun will manifest itself immediately in a test environment. The "performance is bad if column names are ambiguous" footgun is more difficult to observe in test, and may not show up at all if your test environment doesn't mimic prod closely enough. But it will bring down prod with precious little help from your logs/analytics: it will tell you one of your queries is taking exponentially more time than it used to, but can't tell you why. The fact that it takes exponentially more time means that your database servers will slow all requests, perhaps catastrophically. The "fail immediately on ambiguity" footgun will have obvious "query failed because <reason>", which depending on how good your logging is might tell you exactly what the problem is. (I do use SQL at work -- rarely. But in our app a failed query just returns a "fail" error code with no additional information. Soo... yeah.) And in the meantime, the database servers are not performing full row scans, meaning unaffected queries are unaffected.

Again, I'm no expert, but I'm a huge fan of "fail fast" in general. I'd rather have a miswritten query refuse to execute instead of executing poorly. I guess if you have an SLA where you're allowed to ... stretch the truth about the state of your servers, it's better to have everything grind to a half than error immediately. So that way your dashboard stays green. I guess I can see it both ways.

Totally agree, fail fast is nice. That's part of the benefits with the proposed foreign key joins, you would eliminate the class of bugs caused by joining on the wrong columns.
Neither did I. I tend to avoid RBDMS-specific syntax extensions of questionable benefit.

The only thing I really miss in PG is ASOF JOIN. It's possible to implement it via CROSS JOIN LATERAL, but in my case it resulted in nested loops — coming from ClickHouse it bothers me that there is no way (that I know of) to hint PG to use a specific join strategy.

WITH is a keyword already used for SQL common table expressions/subquery syntax. Perhaps use a distinct and different keyword... VIA perhaps?
VIA works for me, but I thought reusing an existing reserved keyword would be less invasive. I don’t think WITH is currently allowed in the FROM clause, so think it should work. But if an entirely new keyword can be accepted, then I agree VIA is nice.
Not sure about this, I wouldn't like having to look up the FK name every time or hope it was named following the convention.

The first thing (among many others) that I would change in SQL is the position of the SELECT clause:

FROM .. JOIN .. SELECT .. WHERE ..

instead of

SELECT .. FROM .. JOIN .. WHERE ..

That would make the construction of many queries more natural.

Never thought about the order, but now when you mention it, I realize I always write

    SELECT
    FROM
and start writing the FROM clause first.

So if we could rewrite history, I agree the order you suggest would make more sense, but it’s probably unrealistic to change it, except for entirely new SQL inspired languages.

To comment on having to look up foreign keys. The idea I had in mind is to allow changing the default formatting of foreign key names, so you could figure out the name, except in special cases such as if there would be two foreign keys referencing the same table. For such cases you should explicitly name the foreign keys.

This is a good use case for an AST-based query compiler which would allow construction of clauses in arbitrary order. Using HoneySQL in Clojure, I frequently would write:

  (-> (from [:cities :c])
      (join [:population :c] [:= :c.name :p.city-name])
      (select :c.name :c.pizza-rank :p.population)
      (order-by [:c.pizza-rank :asc]))
It's handy for a lot of reasons, but unless I had mostly static queries (changing just some where clause params), I would always seek out a AST library rather than attempt string building for a SQL use case.
Following the granularity gradient

FROM .. JOIN .. WHERE .. SELECT

tables -> columns -> rows -> filter

I think they knew that but decided they wanted DBAs to think about what they wanted to end up with before they began writing code, so they moved the last step to the first step. I can get behind that from a pedagogical perspective.

However today, relational databases are far too ubiquitous to always get the level of serious thought and considerations they were once afforded, and are regularly being used by (perish the thought) non-DBAs.

At this point it is muscle memory to start with:

   select count(*)
     from ..
     join .. on ..
 
then comment out the `--count(*)` and build up the output after the body works because I may not know what is available to select before isolating it.
Those were the times where "natural language like" was considered good and useful for use by non experts. It's one of the reasons why the sql syntax is so complex and, I believe, the reason why IBM did go with that order
This is effectively what CTE style syntax gives you. I always find them more intuitive for intermediate to advanced queries for exactly this reason. They’re also much easier for another reader to later come in and deduce what the query is doing.
CTE's are indeed nice, but can also make your queries substantially slower, at least in Postgres
That's mainly true because you don't have the benefit of an index when you join on a CTE, isn't it?
It'a slow even without indexes. In other cases, where the CTE is used multiple times in the same query, it can be faster
A JOIN is simply a special predicate.

    FROM A ... FROM B ... SELECT ... OUTER JOIN A.Foo = B.Bar ... WHERE ...
That also happens to incredibly compatible with code completion.
The issue here is that FROM, WHERE, etc., are all optional, where the operation (SELECT in this case) is not.

If it were a proposal for a SQL21 standard to offer this as an optional method for query processing, I'd be all for it.

However, the idea of "non-optional followed by optional" came from an era where that sort of thing mattered and it made sense.

While I haven't given it serious thought, I've been wishing for something like this

  SELECT u.name, r.role
  FROM users u
  JOIN user_roles r USING FOREIGN KEY
  WHERE u.id = :id
Using this syntax one could optionally specify the foreign key name as well, in case it is ambiguous. It's more explicit than NATURAL JOIN and it feels very SQL-ish to me.

  > FROM permission p
  > LEFT JOIN role r WITH p->permission_role_id_fkey = r
<snip>

You're using the alias "r" here for two different things:

  - a table alias for the role table
  - a whatever (foreign key index object?) alias for p->permission_role_id_fkey
> the special but common case when joining on columns that exactly match a foreign key

I've been programming with RDBMSs since 1996. I'd say that approximately 99% of the thousands of JOINs I've written were based on PKs/FKs.

The example that you are attempting to improve already operates on PKs/FKs.

I don't understand the point of this proposed improvement at all

> You're using the alias "r" here for two different things:

I agree this is a bit confusing. The user "BeefWellington" came up with a better idea, to skip the "= r" part, since it's redundant. That way, you would only need some new keyword, to indicate you want to perform a foreign key join, and specify the referencing or referenced table alias (depending on which direction the join is made, i.e. what table alias that has the foreign key), together with the name of the foreign key.

> The example that you are attempting to improve already operates on PKs/FKs. > I don't understand the point of this proposed improvement at all

The example operates on PKs/FKs column, yes, but they are specified manually. I listed a number of possible benefits under the section "POSSIBLE BENEFITS" in my original post, I think these explains what improvements I specifically see.

You list three proposed benefits. (I'm listing them in the order I will address them):

  1) make non-key joins stand out
  2) conciser syntax
  3) less susceptible to joining on wrong columns
1) I'll concede this. But I don't see enough value in it to add (and force people to learn) an alternative JOIN syntax

2) That's not at all clear from your example. Nor does it necessarily follow from the proposal in general

3) This would be your strongest argument. But it's not really a problem in the real world. If/when this happens, it does so for one of three reasons:

  - the programmer misunderstood the data model
  - the programmer misunderstands JOINs in general - how they work and how to write them
  - the programmer wasn't paying attention to what they were doing - e.g. copy/pasted the wrong thing
All of these are bigger problems - they transcend the the realm of JOINs.

As with 1), I don't see enough value in it to make the overall language (and parsers) more complex.

SQL has lots of warts. It is an unpleasant language. It is a difficult language to learn (initially). But it (mostly) works.

If I was going to advocate for a change like this, I'd propose a new join type called something like FK JOIN:

  SELECT * from table1
  FK JOIN table2
This would work sort of like NATURAL JOIN, but without any possibility of ambiguity (and only on the key column).
> SELECT * from table1 > FK JOIN table2

What if table1 has been joined in twice, to what table alias is table2 joined against?

Example:

   SELECT * FROM table1 a
   CROSS JOIN table1 b
   FK JOIN table2

Would table2 be joined against "b" or "a"? I would guess "b" and guess the idea is to always let FK JOIN join against the previous join?
Yeah, that's a problem.

FK JOIN was a casually tossed off addition that, in retrospect, I shouldn't have included.

As for your other comments, sounds like we are in agreement on (1) and (3), but just weigh pros/cons differently.

> 2) That's not at all clear from your example. Nor does it necessarily follow from the proposal in general

Can you provide a counter-example that will not be conciser when written using the foreign key join syntax?

Have always felt SQL is pointlessly complex for the overwhelmingly common case of inner joining a child table to parent via foreign key to primary key. Surely this could / should be made implicit somehow. I shouldn't even have to name the parent table, just let me select or reference parent table columns via indirection through the fk column should be enough.

I often think half the ORMs in existence might not have been invented if SQL was just a bit more ergonomic.

You can JOIN USING in Postgres. It’s healthy to make it explicitly though, and queries should not be being written all the time anyway.
Yeah ... but I just don't understand, why rely on weak naming conventions when there is an explicit foreign key relationship that tells you the exact column and table? I shouldn't need to even write a join keyword at all, just

    select f.bar_id->some_bar_table_column 
    from foo f
... if the foo table has a bar_id column with a foreign key constraint to bar's primary key.

If the foreign key constraint is defined and unique just use it and don't make me type out all the redundant join rubbish. You could then even do deep nesting with no more syntax:

    select f.bar_id->baz_id->some_baz_table_column 
    from foo f
> "select f.bar_id->some_bar_table_column"

Something like this was actually my first iteration of this idea, to join based on the column name(s). But then someone helped me think about what if the column is involved in two different foreign keys (to two different tables)? Not common, but absolutely possible. That's why just naming the column(s) isn't precise enough, except in most cases.

Even if no such ambiguity was allowed, i.e. if all unique sets of column name(s) referenced exactly one table, you would still need to think about how the syntax would look like for foreign keys on multiple columns.

I guess this comes back to the whole concept of something being ergonomic - how well is it designed to comfortably enable the common use case vs. forcing users to pay a tax and do something annoying on the 95% case to allow for the 5% case to work. The scenario you mention is rare enough in my experience compared to a simple DAG style hierarchical table structure that we should totally optimise the query language for the former and then just add in escape hatches for the less common scenarios.

I'm sort of surprised that there is nothing similar to the CSS SASS/SCSS type idea that has ever taken off in SQL land ...

I don't think something that works in 95% of the cases would be accepted by the SQL committee.

I think at least you would need to find a way to follow foreign keys on multiple columns.

I can see why working with column names would be nice, since we then would not have to deal with foreign key names at all, which is another thing to keep track of mentally. But the only way that would work would be if we would forbid creating multiple foreign keys on the same set of columns, only then would the set of columns uniquely identify what foreign key to follow. This is much more invasive though, as it could break existing data models.

(comment deleted)
You know what I would love beyond all measure?

No longer needing to use commas between the items after the SELECT and before the FROM. Wouldn’t that be nice? We don’t need to use commas in between joins and items in the WHERE clauses. I get that there is probably some syntax reason for this, but I would like to know why.

You can do a lot to a field that isn't just "give me this field name" like running functions, aliasing it as another name, etc. If you're doing a simple:

SELECT a b c d FROM table

I can understand the appeal, but when you're doing:

SELECT CAST(a AS VARCHAR(8)) as cast_a b c d FROM table

There's no obvious syntactical way to separate those out, especially since you can get really complex in how you assemble things like aliases when you get into functions, stored procedures, subselets, etc.

PostgreSQL's cast operator syntax (::DATATYPE) can help a bit but it hasn't been standardized.

Why not steel the Cypher syntax all together and have a match keyword for joins by pkeys and fkeys.
In this example you provided, if Foreign Keys are to be used by their defined name, why would we need to specify which table we're going to join here?

    FROM permission p
    LEFT JOIN role r WITH p->permission_role_id_fkey = r
    LEFT JOIN team_role tr WITH tr->team_role_role_id_fkey = r
    LEFT JOIN team t WITH tr->team_role_team_id_fkey = t
    LEFT JOIN user_role ur WITH ur->user_role_role_id_fkey = r
    LEFT JOIN "user" u WITH ur->user_role_user_id_fkey = u
    WHERE p.id = 1;
Why should this not simply be:

    FROM permission p
    LEFT JOIN role r USING p.permission_role_id_fkey
    LEFT JOIN team_role tr USING tr.team_role_role_id_fkey
    LEFT JOIN team t USING tr.team_role_team_id_fkey
    LEFT JOIN user_role ur USING ur.user_role_role_id_fkey
    LEFT JOIN "user" u USING ur.user_role_user_id_fkey
    WHERE p.id = 1;
The foreign keys here already define the relationships between tables.

To be clear though; there's a really good set of reasons this isn't a great idea IMO, the two simplest being:

1) Now when I'm writing queries I have to go look up the foreign key names. This isn't something that I'm often innately familiar with (unless there's a reliable naming convention; spoiler: there never seems to be). Contrasted with field names, I could easily construct half a dozen quick queries against any DB I regularly use by knowing which tables or fields are there.

2) The example you provide are actually less clear than explicitly stating the joined fields. If I have to come in and troubleshoot something down the line, I then have to spend additional time after viewing the query going in search of what the heck fields are involved in team_role_role_id_fkey, rather than just seeing that it's role.id and team_role.role_id.

> In this example you provided, if Foreign Keys are to be used by their defined name, why would we need to specify which table we're going to join here?

I see, you're right; we don't need to! Thanks for great improvement! I don't think using "USING" is a wise choice though, as that's already a different way of joining. But I agree WITH isn't very nice either. How about "KEY"? I also think we should avoid reusing "." since that will is used to refer to columns, and the FK is not a column. I think we need a separate token, "->", or something else.

With your idea of leaving out the redundant referenced table alias, and using "KEY" instead of "WITH", the FROM clause becomes:

    FROM permission p
    LEFT JOIN role r KEY p->permission_role_id_fkey
    LEFT JOIN team_role tr KEY tr->team_role_role_id_fkey
    LEFT JOIN team t KEY tr->team_role_team_id_fkey
    LEFT JOIN user_role ur KEY ur->user_role_role_id_fkey
    LEFT JOIN "user" u KEY ur->user_role_user_id_fkey
    WHERE p.id = 1;
To comment on your other two comments:

> 1) Now when I'm writing queries I have to go look up the foreign key names. This isn't something that I'm often innately familiar with (unless there's a reliable naming convention; spoiler: there never seems to be). Contrasted with field names, I could easily construct half a dozen quick queries against any DB I regularly use by knowing which tables or fields are there.

Very valid point. For this idea to be convenient, one would also need to make the default name for newly created foreign keys user-definable. Simply using the referenced table name as the name would work fine in PostgreSQL, where constraint names are only required to be unique per table, and not per namespace as the SQL standard dictates (for really no good reason). E.g. "permission_role_id_fkey" would simply be named "role".

But then one might ask: What happens if you create two foreign keys referencing the same table? In PostgreSQL, a number starting at 1 is automatically appended to the name, e.g. if creating another FK on "permissions" referencing "rule", it would be named "permission_role_id_fkey1".

> 2) The example you provide are actually less clear than explicitly stating the joined fields. If I have to come in and troubleshoot something down the line, I then have to spend additional time after viewing the query going in search of what the heck fields are involved in team_role_role_id_fkey, rather than just seeing that it's role.id and team_role.role_id.

I think this complaint is related to the first one you made. If you do have a simple naming convention, you will see what table is referenced. The actual values of the foreign key column(s) is not very interesting, since they are just referring some foreign row's primary/unique column(s). Why would you ever need to "troubleshoot" the actual values? Since having FKs on such column(s), they will always be correct when set, so if something is to be troubleshooted it ought to be some actual data in the referenced table, which you could manually join-in and inspect, also by manually joining using the FKs, to avoid having to key in the values of the FK column(s).

If I was going to do something like this, I would adopt adopt a referencing table scoped name for the referenced table for each foreign key, and then just let you refer to that in the from clause of the query to specify a join on that foreign key relationship.

So, for example, you would have the DDL:

  CREATE TABLE staff (
    id serial primary key,
    name varchar not null,
    manager_id references employees (id) as manager,
  );
Then you could do:

  SELECT staff.name, manager.name 
  FROM staff 
  INNER JOIN staff->manager as manager
Which would be equivalent to:

  SELECT employee.name, manager.name
  FROM staff as employee
  INNER JOIN staff as manager on (
    employee.manager_id = manager.id
  )
Maybe even allow using the table->relationship name in the SELECT (or WHERE) clause for an implicit inner join, so you could just do this for the above:

  SELECT staff.name, staff->manager.name
  FROM staff
Or this to get a list of staff by manager name:

  SELECT staff.*
  FROM staff
  WHERE staff->manager.name = "John Smith"

Of course, if you are doing that, maybe adopt a syntax for implicit outer joins, too, then you can get a list of employees with no subordinates with:

  SELECT staff*->manager.*
  FROM staff
  WHERE staff.id IS NULL
Which would be equivalent to:

  SELECT manager.*
  FROM staff AS employee
  RIGHT OUTER JOIN staff AS manager ON (
    employee.manager_id = manager.id
  )
I think these are all interesting ideas! I also think making changes to the SELECT clause of the SQL language could be more difficult though. You also have to think about what type of join something like "SELECT staff->manager.name" would cause under the hood, would it be a LEFT JOIN or an INNER JOIN?
What is it with people pushing the c style "->" everywhere. Dot works just fine, is just one char, no shift key required and it is still unambiguous. Stop this "->" nonsense. Even in c it shouldn't be required, the intent is clear and unambiguous if dot is used.
Since dot "." is already used to refer to columns, reusing it to refer to foreign keys would make parsing a lot more complex and error-prone, since the grammar would be context-sensitive, you would need to take into account you are parsing a foreign key join expression first, i.e. you wouldn't know what "foo->bar" meant without looking at the surrounding code.

While probably possible, I think it would be less clear and also increase parser complexity more than necessary.

My original idea was actually to use dot ".", but then others explained the above to me, which made me rethink.

> Since dot "." is already used to refer to columns

But is it really? If you think of it as referencing "some attribute of whatever is mentioned before it", the period could also refer to constraints, indexes, or whatever.