More complex SELECTs
We've seen how to create simple queries from our schema. Beam supports other clauses in the SQL SELECT statement.
For these examples, we're going to use the beam-sqlite
backend with the
provided sample Chinook database. The Chinook database schema is modeled after a
fictional record store. It provides several tables containing information on the
music as well as the billing operations. Thus, it provides a good 'real-world'
demonstration of beam's capabalities.
First, create a SQLite database from the included example.
$ sqlite3 chinook.db < beam-sqlite/examples/chinook.sql
Now, load the chinook database schema in GHCi.
Prelude Database.Beam.Sqlite> :load beam-sqlite/examples/Chinook/Schema.hs
Prelude Chinook.Schema> chinook <- open "chinook.db"
One more thing, before we see more complex examples, let's define a quick utility function.
Prelude Chinook.Schema> let withConnectionTutorial = runBeamSqliteDebug putStrLn chinook
Let's test it!
We can run all our queries like:
withConnectionTutorial $ runSelectReturningList $ select $ <query>
Let's select all the tracks.
withConnectionTutorial $ runSelectReturningList $ select $ all_ (track chinookDb)
For the rest of the guide, we will also show the generated SQL code for both sqlite and postgres.
all_ (track chinookDb)
SELECT "t0"."TrackId" AS "res0",
"t0"."Name" AS "res1",
"t0"."AlbumId" AS "res2",
"t0"."MediaTypeId" AS "res3",
"t0"."GenreId" AS "res4",
"t0"."Composer" AS "res5",
"t0"."Milliseconds" AS "res6",
"t0"."Bytes" AS "res7",
"t0"."UnitPrice" AS "res8"
FROM "Track" AS "t0"
SELECT "t0"."TrackId" AS "res0",
"t0"."Name" AS "res1",
"t0"."AlbumId" AS "res2",
"t0"."MediaTypeId" AS "res3",
"t0"."GenreId" AS "res4",
"t0"."Composer" AS "res5",
"t0"."Milliseconds" AS "res6",
"t0"."Bytes" AS "res7",
"t0"."UnitPrice" AS "res8"
FROM "Track" AS "t0";
-- With values: []
Returning a subset of columns
Oftentimes we only care about the value of a few columns, rather than every
column in the table. Beam fully supports taking projections of tables. As said
before, Q
is a Monad
. Thus, we can use monadic do
notation to only select a
certain subset of columns. For example, to fetch only the name of every track:
do tracks <- all_ (track chinookDb)
pure (trackName tracks)
SELECT "t0"."Name" AS "res0"
FROM "Track" AS "t0"
SELECT "t0"."Name" AS "res0"
FROM "Track" AS "t0";
-- With values: []
Notice that beam has properly written the SELECT
projection to only include
the Name
field.
We can also return multiple fields, by returning a tuple. Perhaps we would also like to know the composer:
do tracks <- all_ (track chinookDb)
pure (trackName tracks, trackComposer tracks)
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1"
FROM "Track" AS "t0"
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1"
FROM "Track" AS "t0";
-- With values: []
You can also return arbitrary expressions in the projection. For example to return the name, composer, unit price, and length in seconds (where the database stores it in milliseconds):
do tracks <- all_ (track chinookDb)
pure (trackName tracks, trackComposer tracks, trackMilliseconds tracks `div_` 1000)
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1",
("t0"."Milliseconds") / (1000) AS "res2"
FROM "Track" AS "t0"
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1",
("t0"."Milliseconds") / (?) AS "res2"
FROM "Track" AS "t0";
-- With values: [SQLInteger 1000]
Beam includes instances to support returning up to 8-tuples. To return more, feel free to nest tuples. As an example, we can write the above query as
do tracks <- all_ (track chinookDb)
pure ((trackName tracks, trackComposer tracks), trackMilliseconds tracks `div_` 1000)
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1",
("t0"."Milliseconds") / (1000) AS "res2"
FROM "Track" AS "t0"
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1",
("t0"."Milliseconds") / (?) AS "res2"
FROM "Track" AS "t0";
-- With values: [SQLInteger 1000]
Notice that the nesting of tuples does not affect the generated SQL projection. The tuple structure is only used when reading back the row from the database.
The Q
monad is perfectly rule-abiding, which means it also implements a valid
Functor
instance. Thus the above could more easily be written.
fmap (\tracks -> (trackName tracks, trackComposer tracks, trackMilliseconds tracks `div_` 1000)) $
all_ (track chinookDb)
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1",
("t0"."Milliseconds") / (1000) AS "res2"
FROM "Track" AS "t0"
SELECT "t0"."Name" AS "res0",
"t0"."Composer" AS "res1",
("t0"."Milliseconds") / (?) AS "res2"
FROM "Track" AS "t0";
-- With values: [SQLInteger 1000]
WHERE
clause
We've seen how to use all_
to select all rows of a table. Sometimes, you would
like to filter results based on the result of some condition. For example,
perhaps you would like to fetch all customers whose names start with "Jo". We
can filter over results using the filter_
function.
filter_ (\customer -> customerFirstName customer `like_` "Jo%") $
all_ (customer chinookDb)
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ("t0"."FirstName") LIKE ('Jo%')
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ("t0"."FirstName") LIKE (?);
-- With values: [SQLText "Jo%"]
You can use (&&.)
and (||.)
to combine boolean expressions, as you'd expect.
For example, to select all customers whose first name begins with "Jo", last
name begins with "S", and who live in either California or Washington:
filter_ (\customer -> ((customerFirstName customer `like_` "Jo%") &&. (customerLastName customer `like_` "S%")) &&.
(addressState (customerAddress customer) ==. just_ "CA" ||. addressState (customerAddress customer) ==. just_ "WA")) $
all_ (customer chinookDb)
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE ('Jo%'))
AND (("t0"."LastName") LIKE ('S%')))
AND ((("t0"."State") IS NOT DISTINCT
FROM ('CA'))
OR (("t0"."State") IS NOT DISTINCT
FROM ('WA')))
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE (?))
AND (("t0"."LastName") LIKE (?)))
AND ((CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END)
OR (CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END));
-- With values: [SQLText "Jo%",SQLText "S%",SQLText "CA",SQLInteger 1,SQLText "CA",SQLInteger 0,SQLText "CA",SQLText "WA",SQLInteger 1,SQLText "WA",SQLInteger 0,SQLText "WA"]
Note
We had to use the just_
function above to compare
addressState (customerAddress customer)
. This is because addressState
(customerAddress customer)
represents a nullable column which beam types as
Maybe Text
. Just as in Haskell, we need to explicitly unwrap the Maybe
type. This is an example of beam offering stronger typing than SQL itself.
LIMIT
/OFFSET
support
The limit_
and offset_
functions can be used to truncate the result set at a
certain length and fetch different portions of the result. They correspond to
the LIMIT
and OFFSET
SQL constructs.
limit_ 10 $ offset_ 100 $
filter_ (\customer -> ((customerFirstName customer `like_` "Jo%") &&. (customerLastName customer `like_` "S%")) &&.
(addressState (customerAddress customer) ==. just_ "CA" ||. addressState (customerAddress customer) ==. just_ "WA")) $
all_ (customer chinookDb)
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE ('Jo%'))
AND (("t0"."LastName") LIKE ('S%')))
AND ((("t0"."State") IS NOT DISTINCT
FROM ('CA'))
OR (("t0"."State") IS NOT DISTINCT
FROM ('WA')))
LIMIT 10
OFFSET 100
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE (?))
AND (("t0"."LastName") LIKE (?)))
AND ((CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END)
OR (CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END))
LIMIT 10
OFFSET 100;
-- With values: [SQLText "Jo%",SQLText "S%",SQLText "CA",SQLInteger 1,SQLText "CA",SQLInteger 0,SQLText "CA",SQLText "WA",SQLInteger 1,SQLText "WA",SQLInteger 0,SQLText "WA"]
Note
Nested limit_
s and offset_
s compose in the way you'd expect without
generating extraneous subqueries.
Warning
Note that the order of the limit_
and offset_
functions matter.
Offseting an already limited result is not the same as limiting an offseted
result. For example, if you offset three rows into a limited set of five
results, you will get at most two rows. On the other hand, if you offset
three rows and then limit the result to the next five, you may get up to
five. Beam will generate exactly the query you specify. Notice the
difference below, where the order of the clauses made beam generate a query
that returns no results.
offset_ 100 $ limit_ 10 $
filter_ (\customer -> ((customerFirstName customer `like_` "Jo%") &&. (customerLastName customer `like_` "S%")) &&.
(addressState (customerAddress customer) ==. just_ "CA" ||. addressState (customerAddress customer) ==. just_ "WA")) $
all_ (customer chinookDb)
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE ('Jo%'))
AND (("t0"."LastName") LIKE ('S%')))
AND ((("t0"."State") IS NOT DISTINCT
FROM ('CA'))
OR (("t0"."State") IS NOT DISTINCT
FROM ('WA')))
LIMIT 0
OFFSET 100
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE (?))
AND (("t0"."LastName") LIKE (?)))
AND ((CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END)
OR (CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END))
LIMIT 0
OFFSET 100;
-- With values: [SQLText "Jo%",SQLText "S%",SQLText "CA",SQLInteger 1,SQLText "CA",SQLInteger 0,SQLText "CA",SQLText "WA",SQLInteger 1,SQLText "WA",SQLInteger 0,SQLText "WA"]
Backends often differ as to how they implement LIMIT/OFFSET. For example, SQLite
requires that LIMIT
always be given if an OFFSET
is provided. Beam correctly
handles this behavior.
offset_ 100 $
filter_ (\customer -> ((customerFirstName customer `like_` "Jo%") &&. (customerLastName customer `like_` "S%")) &&.
(addressState (customerAddress customer) ==. just_ "CA" ||. addressState (customerAddress customer) ==. just_ "WA")) $
all_ (customer chinookDb)
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE ('Jo%'))
AND (("t0"."LastName") LIKE ('S%')))
AND ((("t0"."State") IS NOT DISTINCT
FROM ('CA'))
OR (("t0"."State") IS NOT DISTINCT
FROM ('WA')))
OFFSET 100
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
WHERE ((("t0"."FirstName") LIKE (?))
AND (("t0"."LastName") LIKE (?)))
AND ((CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END)
OR (CASE
WHEN (("t0"."State") IS NULL)
AND ((?) IS NULL) THEN ?
WHEN (("t0"."State") IS NULL)
OR ((?) IS NULL) THEN ?
ELSE ("t0"."State")=(?)
END))
LIMIT -1
OFFSET 100;
-- With values: [SQLText "Jo%",SQLText "S%",SQLText "CA",SQLInteger 1,SQLText "CA",SQLInteger 0,SQLText "CA",SQLText "WA",SQLInteger 1,SQLText "WA",SQLInteger 0,SQLText "WA"]
Notice that the SQLite query output has provided a dummy LIMIT -1
clause,
while the Postgres query has not.
DISTINCT
support
SQL can only return unique results from a query through the SELECT DISTINCT
statement. Beam supports this using the nub_
command. For example, to get all
the unique postal codes where our customers live.
nub_ $ fmap (addressPostalCode . customerAddress) $
all_ (customer chinookDb)
SELECT DISTINCT "t0"."PostalCode" AS "res0"
FROM "Customer" AS "t0"
SELECT DISTINCT "t0"."PostalCode" AS "res0"
FROM "Customer" AS "t0";
-- With values: []
VALUES
support
Sometimes you want to select from an explicit group of values. This is most helpful if you want to join against a set of values that isn't in the database.
For example, to get all customers we know to be in New York, California, and Texas.
do c <- all_ (customer chinookDb)
st <- values_ [ "NY", "CA", "TX" ]
guard_' (just_ st ==?. addressState (customerAddress c))
pure c
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2",
"t0"."Company" AS "res3",
"t0"."Address" AS "res4",
"t0"."City" AS "res5",
"t0"."State" AS "res6",
"t0"."Country" AS "res7",
"t0"."PostalCode" AS "res8",
"t0"."Phone" AS "res9",
"t0"."Fax" AS "res10",
"t0"."Email" AS "res11",
"t0"."SupportRepId" AS "res12"
FROM "Customer" AS "t0"
CROSS JOIN (
VALUES ('NY'), ('CA'), ('TX')) AS "t1"("res0")
WHERE ("t1"."res0") = ("t0"."State")
Note
beam-sqlite
does not support VALUES
clauses anywhere within a
query, but only within a common table expression.
Ad-hoc queries
Sometimes you want to quickly query a database without having to write out all the boilerplate. Beam supports this with a feature called 'ad-hoc' queries.
For example, to get all the names of customers, without having to use
chinookDb
at all.
To use this functionality, import Database.Beam.Query.Adhoc
. Note
that we use TypeApplications
to give each field an explicit type. If
you don't, GHC will often infer the type, but it's nice to be explicit.
-- import qualified Database.Beam.Query.Adhoc as Adhoc
do ( cId, firstName, lastName )
<- Adhoc.table_ Nothing {- Schema Name -}
"Customer"
( Adhoc.field_ @Int32 "CustomerId"
, Adhoc.field_ @Text "FirstName"
, Adhoc.field_ @Text "LastName" )
guard_ (firstName `like_` "Jo%")
return (cId, firstName, lastName)
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2"
FROM "Customer" AS "t0"
WHERE ("t0"."FirstName") LIKE ('Jo%')
SELECT "t0"."CustomerId" AS "res0",
"t0"."FirstName" AS "res1",
"t0"."LastName" AS "res2"
FROM "Customer" AS "t0"
WHERE ("t0"."FirstName") LIKE (?);
-- With values: [SQLText "Jo%"]