Esqueleto Select Syntax

Our instinct as Haskell programmers is to follow the types around and make sure the way we’re composing things works. Normally, this makes sense.

However, in the case of translating SQL to Esqueleto syntax, I find it’s often easier to view the Haskell code as just the end product of some indifferent translation mechanism (translate :: SQL -> Haskell), which doesn’t have very helpful types until it’s completed the translation. This definitely has drawbacks, such as making it harder to iteratively add things, but if you already have your SQL query handy, then translating it to Esqueleto becomes a purely syntactic game of shuffling terms around.

For convenience, I’ll be using the example schema from https://hackage.haskell.org/package/esqueleto-3.3.3.2/docs/Database-Esqueleto.html, but hopefully the exact schema won’t matter too much here. It looks something like this:

Person
  name String
  age Int Maybe
  deriving Eq Show

BlogPost
  title String
  authorId PersonId
  deriving Eq Show

I’m also using some example queries taken directly from that page. Mostly what I’ve done here is just adding a bit more description to how they get translated, and color-coding for clarity. Hopefully, this means that if I’ve made any mistakes, the Esqueleto docs can clear them up.

Shuffling words

Let’s start with a basic, yet common, query. I’ve color-coded each of the elements so it’s easier to follow along with where they move to.

SELECT * FROM Person;

SELECT becomes select, and FROM becomes from, of course. But the order is weird, as both come at the start in esqueleto. We’ve also inserted some $, and added a hole for what to add next.

select $ from $ _

What comes next is the relevant tables. In this case, Person is the only table we care about. We make a function from the relevant tables, to the result. This means making a lambda from person to the result. Right now, our result is just an empty do block.

select $ from $
  \person -> do
    _

What should our result be? In this case, our SQL query returns *. But what does * really mean? We can think of it as Person.*, or “every column of the Person table”. In Haskell, we represented the Person table with person (the argument to our lambda), so our result here is just returning person again.

select $ from $
  \person -> do
    return person

Where clauses

Here’s a similar query, but with an added WHERE clause. Since it’s fairly straightforward to know what keywords like SELECT and FROM map to in Esqueleto, I’ll save on continuing to color them.

SELECT * FROM Person WHERE Person.name = “John”;

This query has the same general outline as the last one, but we’ve added a _ hole for the where clause.

select $ from $
  \person -> do
     _
    return person

Let’s focus on the where clause specifically.

WHERE Person.name = “John”

WHERE translates to where_, and = translates to ==., with two holes to fill in now

where_ ( _ ==. _ )

For the orange hole, we’re looking for the exact string "John". However, Esqueleto’s values are not quite the same as Haskell values. To lift the text "John" into an Esqueleto context, we need to use val "John"

where_ ( _ ==. val “John”)

There are other ways to lift Haskell values into an Esqueleto context. For example, we can use just x as a convenient replacement for val (Just x) when lifting a Maybe.

This is a nice segue into filling in the green hole. We know that we want to translate Person.name. This is a “projection” of a Person to a name.

As with just and val, Esqueleto tries to be nice, and provides different projection operators for when something may exist (?.) and when something is known to exist (^.), because in SQL queries we often have to deal with possibly-nonexistent data.

We can resolve that here by looking at the schema.

Person
  name String
  age Int Maybe

Because name is not a Maybe, we use the operator ^.. If instead we were looking for Person.age, we would use ?..

Our final translation of Person.name is person ^. PersonName. We get person from our lambda introducing the tables we’re using, and we get PersonName from the database schema, which says that we have a projection from Person to a String called name.

where_ (person ^. PersonName ==. val “John”)

Now we can slot the where clause back into our overall query, to get this final translation:

select $ from $
  \person -> do
    where_ (person ^. PersonName ==. val “John”)
    return person

Joins

Now let’s try a query involving more than one table.

SELECT * FROM Person INNER JOIN BlogPost ON Person.id = BlogPost.authorId;

Again, the general query structure is very similar. Note that the second hole is in the same place as the hole for the WHERE clause was in the last example, inside the do block. This is also where we’d put other clauses, like orderBy and groupBy, though we won’t need those two here. Here, we’ll be using on.

select $ from $
  \ _ -> do
     _
    return _

Remember that the first hole is where our “table information” goes. Because we have more than one table now, we’ll need to express that our multiple tables are joined somehow. In this case, we can do that with InnerJoin. There’s also LeftOuterJoin, FullOuterJoin, etc., corresponding to the various types of joins in SQL.

select $ from $
  \(person `InnerJoin` blogPost) -> do
     _
    return _

* in SQL can represent quite a few different things, so it can be hard to translate. In this example, it represents all the information from both the person and blogPost tables, which we can represent by the tuple (person, blogPost).

select $ from $
  \(person `InnerJoin` blogPost) -> do
     _
    return (person, blogPost)

Now we’re left with just the ON clause to translate.

ON Person.id = BlogPost.authorId

As with WHERE, let’s translate this clause separately and then paste it back into the big query. ON becomes on, and = becomes ==.

on ( _ ==. _ )

We have Person.id and BlogPost.authorId. Checking the schema, and because we’re using InnerJoin, neither of these should be Maybe. So we can use ^. to project these fields.

on (person ^. PersonId ==. blogPost ^. BlogPostAuthorId)

Now we put this on clause back where it belongs in the query

select $ from $
  \(person `InnerJoin` blogPost) -> do
    on (person ^. PersonId ==. blogPost ^. BlogPostAuthorId)
    return (person, blogPost)