You’ll need a Haskell project named using-plans for this tutorial.
The setup is identical to the setup in Getting Started
aside from the package name, so we’ll avoid explaining it again here.
This example shows Plans. A Plan contains one or more SQL select statements, and when the first is run, its result is passed into the next.
The goal of plans is to prevent n+1 queries, while still allowing a plan for a single item to be modified to execute against many items.
The code contains a detailed description, so this document will just focus on examples.
This example has a table with students, and a table with classes they can take.
Because multiple students can be enrolled in multiple classes, it is a many-to-many relationship, which can be modelled with a separate table with two foreign keys on it.
These tables are defined in a manner similar to previous tutorials. Note the
foreign keys on the student_class table. Begin your src/Main.hs like this:
{-# LANGUAGE QualifiedDo #-}
module Main
( main
) where
import qualified Orville.PostgreSQL as O
import qualified Orville.PostgreSQL.AutoMigration as AutoMigration
import qualified Orville.PostgreSQL.Plan as Plan
import qualified Orville.PostgreSQL.Plan.Syntax as PlanSyntax
import Data.List (sort)
import Data.List.NonEmpty (NonEmpty((:|)))
import qualified Data.Int as Int
import qualified Data.Text as T
-------------
-- Student --
-------------
type StudentId = Int.Int32
type StudentName = T.Text
type StudentAge = Int.Int32
data Student = Student
{ studentId :: StudentId
, studentName :: StudentName
, studentAge :: StudentAge
}
deriving Show
studentIdField :: O.FieldDefinition O.NotNull StudentId
studentIdField =
O.integerField "id"
studentNameField :: O.FieldDefinition O.NotNull StudentName
studentNameField =
O.unboundedTextField "name"
studentAgeField :: O.FieldDefinition O.NotNull StudentAge
studentAgeField =
O.integerField "age"
studentMarshaller :: O.SqlMarshaller Student Student
studentMarshaller =
Student
<$> O.marshallField studentId studentIdField
<*> O.marshallField studentName studentNameField
<*> O.marshallField studentAge studentAgeField
studentTable :: O.TableDefinition (O.HasKey StudentId) Student Student
studentTable =
O.mkTableDefinition "plan_demo_student" (O.primaryKey studentIdField) studentMarshaller
----------------------------
-- Student to Class Table --
----------------------------
studentClassIdField :: O.FieldDefinition O.NotNull Int.Int32
studentClassIdField =
O.integerField "id"
studentClassClassIdField :: O.FieldDefinition O.NotNull Int.Int32
studentClassClassIdField =
O.integerField "class_id"
studentClassStudentIdField :: O.FieldDefinition O.NotNull Int.Int32
studentClassStudentIdField =
O.integerField "student_id"
data StudentClass = StudentClass
{ studentClassId :: Int.Int32
, studentClassClassId :: Int.Int32
, studentClassStudentId :: Int.Int32
}
studentClassMarshaller :: O.SqlMarshaller StudentClass StudentClass
studentClassMarshaller =
StudentClass
<$> O.marshallField studentClassId studentClassIdField
<*> O.marshallField studentClassClassId studentClassClassIdField
<*> O.marshallField studentClassStudentId studentClassStudentIdField
studentClassTable :: O.TableDefinition (O.HasKey Int.Int32) StudentClass StudentClass
studentClassTable =
O.addTableConstraints
[ O.foreignKeyConstraint (O.tableIdentifier classTable) $
O.foreignReference (O.fieldName studentClassClassIdField) (O.fieldName classIdField) :| []
, O.foreignKeyConstraint (O.tableIdentifier studentTable) $
O.foreignReference (O.fieldName studentClassStudentIdField) (O.fieldName studentIdField) :| []
]
$ O.mkTableDefinition "plan_demo_student_class" (O.primaryKey studentClassIdField) studentClassMarshaller
-----------
-- Class --
-----------
classIdField :: O.FieldDefinition O.NotNull Int.Int32
classIdField =
O.integerField "id"
classSubjectField :: O.FieldDefinition O.NotNull T.Text
classSubjectField =
O.unboundedTextField "subject"
data Class = Class
{ classId :: Int.Int32
, classSubject :: T.Text
}
deriving (Show, Eq, Ord)
classMarshaller :: O.SqlMarshaller Class Class
classMarshaller =
Class
<$> O.marshallField classId classIdField
<*> O.marshallField classSubject classSubjectField
classTable :: O.TableDefinition (O.HasKey Int.Int32) Class Class
classTable =
O.mkTableDefinition "plan_demo_class" (O.primaryKey classIdField) classMarshaller
The three tables are declared, we’ll proceed to the main function, which
ensures some sample data is available, and then queries it in different ways.
Since we have foreign key constraints, we have to create and delete the data in an order that doesn’t violate these constraints. Orville will throw a Haskell runtime exception upon execution of an SQL statement that violates a constraint.
main :: IO ()
main = do
pool <-
O.createConnectionPool
O.ConnectionOptions
{ O.connectionString = "host=localhost user=postgres password=postgres"
, O.connectionNoticeReporting = O.DisableNoticeReporting
, O.connectionPoolStripes = O.OneStripePerCapability
, O.connectionPoolLingerTime = 10
, O.connectionPoolMaxConnections = O.MaxConnectionsPerStripe 1
}
O.runOrville pool $ do
AutoMigration.autoMigrateSchema AutoMigration.defaultOptions [AutoMigration.SchemaTable studentTable, AutoMigration.SchemaTable classTable, AutoMigration.SchemaTable studentClassTable]
_ <- O.deleteEntity studentClassTable 0
_ <- O.deleteEntity studentClassTable 1
_ <- O.deleteEntity studentClassTable 2
_ <- O.deleteEntity classTable 0
_ <- O.deleteEntity classTable 1
_ <- O.deleteEntity classTable 2
_ <- O.deleteEntity studentTable 0
_ <- O.deleteEntity studentTable 1
_ <- O.insertEntity studentTable Student { studentId = 0, studentName = T.pack "Name", studentAge = 91 }
_ <- O.insertEntity studentTable Student { studentId = 1, studentName = T.pack "Other Name", studentAge = 42 }
_ <- O.insertEntity classTable Class { classId = 0, classSubject = T.pack "Painting" }
_ <- O.insertEntity classTable Class { classId = 1, classSubject = T.pack "Cooking" }
_ <- O.insertEntity classTable Class { classId = 2, classSubject = T.pack "Swimming" }
_ <- O.insertEntity studentClassTable $ StudentClass {studentClassId=0, studentClassClassId=0, studentClassStudentId=0}
_ <- O.insertEntity studentClassTable $ StudentClass {studentClassId=1, studentClassClassId=2, studentClassStudentId=0}
_ <- O.insertEntity studentClassTable $ StudentClass {studentClassId=2, studentClassClassId=1, studentClassStudentId=1}
pure ()
Now that the data is available, let’s first show off a simple plan which is
already more powerful than findEntity. findMaybeOne takes the table, and
the field to filter on, whereas findEntity only works using the primary key.
The plan returned by findMaybeOne is like a parameterized query: it doesn’t
yet contain the actual value to filter for.
But upon execution, that value has to be provided, and it has to match the type
of the Plan. execute is in MonadOrville just like findEntity, so from here
on out, it is familiar territory.
print =<< O.runOrville pool (Plan.execute (Plan.findMaybeOne studentTable studentIdField) 0)
Just to demonstrate that you can filter on other fields too, let’s search for a specific student using their name. Note that Orville doesn’t have any type-level checks for whether there is an index on the field.
print =<< O.runOrville pool (Plan.execute (Plan.findMaybeOne studentTable studentNameField) (T.pack "Other Name"))
A plan that takes a single argument and returns a single result can be passed
into planList to make it work on multiple values. Note how execute now
takes a list instead of just a single value.
print =<< O.runOrville pool (Plan.execute (Plan.planList (Plan.findMaybeOne studentTable studentNameField)) [T.pack "Other Name", T.pack "Name"])
Remember that a plan is just a list of SQL statements. This can be used in a
way similar to how JOIN is used in SQL.
The function chain is used to chain these statements together. After the
first statement is executed and its result has been sent to the client, Orville
allows for manipulating the value with focusParam before using it in a step.
For simplicity, we’ll use findOne here which is like findMaybeOne, but
throws an exception when it fails to find something. In practice, make sure to
only use findOne when there are constraints or there is otherwise certainty
that a row exists.
The following plan will:
1. find a student, given a name. Since this is the first step of the plan, the
name is the input of the plan.
1. using the ID of the student, find a row in student_class that matches on
the studentClassStudentIdField. Note that Orville doesn’t check that the
fields are actually comparable, or that it makes sense to compare them!
1. using the ID of the StudentClass, find a row in class that matches on the class_id.
(print =<<) . O.runOrville pool $ Plan.execute
( Plan.findOne studentTable studentNameField
`Plan.chain` Plan.focusParam studentId (Plan.findOne studentClassTable studentClassStudentIdField)
`Plan.chain` Plan.focusParam studentClassClassId (Plan.findOne classTable classIdField)
)
(T.pack "Other Name")
Expanding on the previous example, let’s find all the names of the classes they
attend, instead of just one. This also lets us avoid unsafe uses of findOne.
Unlike the previous version, whose middle findOne fails when a student
doesn’t attend any classes, this new version doesn’t throw an exception in that
case. Because of the foreign key constraint, the final findOne can’t fail, so
we keep it.
Let’s extract the Student -> [Class] part to its own Plan, which we’ll then
use for the final expression. Note how the following definition is similar to
the last two lines of the plan above.
let
studentToClassesPlan :: Plan.Plan scope Student [Class]
studentToClassesPlan =
Plan.focusParam studentId (Plan.findAll studentClassTable studentClassStudentIdField)
`Plan.chain` Plan.planList (Plan.focusParam studentClassClassId $ Plan.findOne classTable classIdField)
Remember that plans solve the n+1 problem by scaling easily from a single item
to multiple items. This means that while studentToClassesPlan executes as two
SQL statements, planList studentToClassesPlan, a version that works on
multiple elements, also executes as two SQL statements.
You can verify this using explain, which shows the generated SQL. Note how
both of these lists have two SQL statements in them:
print $ Plan.explain studentToClassesPlan
print $ Plan.explain (Plan.planList studentToClassesPlan)
With that in mind, let’s use studentToClassesPlan with findAll and
planList to get a list of classes for each matching student.
(We sort the inner lists below so that the result is deterministic.)
(print =<<) . fmap (fmap sort) . O.runOrville pool $ Plan.execute
( Plan.findAll studentTable studentNameField
`Plan.chain` Plan.planList studentToClassesPlan
)
(T.pack "Name")
Running a sub-plan only once
A plan doesn’t notice when the same sub-plan appears in it more than once.
Every occurrence runs its own query. For example, this plan fetches a student
together with their age, but findStudentByName appears twice, once for each
side of <*>:
let
findStudentByName :: Plan.Plan scope StudentName Student
findStudentByName =
Plan.findOne studentTable studentNameField
studentAndAgeRepeated :: Plan.Plan scope StudentName (Student, StudentAge)
studentAndAgeRepeated =
(,)
<$> findStudentByName
<*> (studentAge <$> findStudentByName)
print ("repeated", Plan.explain studentAndAgeRepeated)
explain lists the student query twice, so the plan runs two SQL statements
where one would do. There are several ways to run the student query only once.
When the rest of the plan only computes values from the shared result, fmap
over the plan once:
let
studentAndAgeMapped :: Plan.Plan scope StudentName (Student, StudentAge)
studentAndAgeMapped =
(\student -> (student, studentAge student)) <$> findStudentByName
print ("mapped", Plan.explain studentAndAgeMapped)
When the rest of the plan runs further queries, or needs several intermediate
results, use bind. It runs a plan once and hands its result to a function as
a Planned value. use turns a Planned value back into a plan that returns
it, and using feeds it as input to another plan, such as
Plan.using student studentToClassesPlan. Neither runs the bound plan again,
however many times they are used:
let
studentAndAgeBound :: Plan.Plan scope StudentName (Student, StudentAge)
studentAndAgeBound =
Plan.bind findStudentByName $ \student ->
(,)
<$> Plan.use student
<*> Plan.use (studentAge <$> student)
print ("bound", Plan.explain studentAndAgeBound)
Orville.PostgreSQL.Plan.Syntax exports bind as >>=, so with the
QualifiedDo extension (enabled at the top of Main.hs) the same plan can be
written with do notation. Each <- binds a Planned value:
let
studentAndAgeDo :: Plan.Plan scope StudentName (Student, StudentAge)
studentAndAgeDo = PlanSyntax.do
student <- findStudentByName
(,) <$> Plan.use student <*> Plan.use (studentAge <$> student)
print ("do", Plan.explain studentAndAgeDo)
All three alternatives run one SQL statement. Note that using doesn’t share
the plan it is given: writing Plan.using student studentToClassesPlan twice
would run the class queries twice. Bind its result once and use it instead.
You can build and execute this as usual:
stack build
stack exec using-plans
And you should see the following output:
Just (Student {studentId = 0, studentName = "Name", studentAge = 91})
Just (Student {studentId = 1, studentName = "Other Name", studentAge = 42})
[Just (Student {studentId = 1, studentName = "Other Name", studentAge = 42}),Just (Student {studentId = 0, studentName = "Name", studentAge = 91})]
Class {classId = 1, classSubject = "Cooking"}
["SELECT \"student_id\",\"id\",\"class_id\",\"student_id\" FROM \"plan_demo_student_class\" WHERE (\"student_id\") = ($1)","SELECT \"id\",\"id\",\"subject\" FROM \"plan_demo_class\" WHERE (\"id\") IN ($1, $2)"]
["SELECT \"student_id\",\"id\",\"class_id\",\"student_id\" FROM \"plan_demo_student_class\" WHERE (\"student_id\") IN ($1, $2)","SELECT \"id\",\"id\",\"subject\" FROM \"plan_demo_class\" WHERE (\"id\") IN ($1, $2)"]
[[Class {classId = 0, classSubject = "Painting"},Class {classId = 2, classSubject = "Swimming"}]]
("repeated",["SELECT \"name\",\"id\",\"name\",\"age\" FROM \"plan_demo_student\" WHERE (\"name\") = ($1)","SELECT \"name\",\"id\",\"name\",\"age\" FROM \"plan_demo_student\" WHERE (\"name\") = ($1)"])
("mapped",["SELECT \"name\",\"id\",\"name\",\"age\" FROM \"plan_demo_student\" WHERE (\"name\") = ($1)"])
("bound",["SELECT \"name\",\"id\",\"name\",\"age\" FROM \"plan_demo_student\" WHERE (\"name\") = ($1)"])
("do",["SELECT \"name\",\"id\",\"name\",\"age\" FROM \"plan_demo_student\" WHERE (\"name\") = ($1)"])