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.
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
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
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)