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)