Select¶
In this chapter we look at reading data in detail. We already know that the
type defines the shape of the result of selectSch: which fields to read,
which related tables to pull in, and whether they are nested as a list or as a
single row. Here we see which result shapes there are, and then move on to
describing the query: conditions, following relations, ordering, limiting the
number of rows, aggregates and, finally, the way out for when pg-schema
cannot express what you need.
Data for the examples¶
The examples of this chapter are in tutorial/app/app/Tut/Select.hs. Before
them the program recreates the schema and fills it with a small data set: four
users, three of whom have a manager, four projects in different statuses and
six tasks. Here is how the data is filled in:
Filling the database for the chapter's examples
type UserIn = "id" := Int64 :. "name" := Text :. "email" := Maybe Text
:. "managerId" := Maybe Int64
type ProjectIn = "id" := Int64 :. "ownerId" := Int64 :. "title" := Text
:. "status" := ProjectStatus :. "tags" := PgArr Text
type TaskIn = "projectId" := Int64 :. "seq" := Int32 :. "title" := Text
:. "priority" := Int32 :. "payload" := Maybe Payload :. "watchers" := PgArr Int64
_ <- insertSch_ (MyAnn "users") conn $ rowsOf @UserIn
[ 1 :. "Alice" :. Just "alice@example.com" :. Nothing
, 2 :. "Bob" :. Just "bob@example.com" :. Just 1
, 3 :. "Carol" :. Nothing :. Just 1
, 4 :. "Dave" :. Just "dave@example.com" :. Just 2 ]
_ <- insertSch_ (MyAnn "projects") conn $ rowsOf @ProjectIn
[ 1 :. 1 :. "Website" :. Project_status_active :. pgArr' ["web", "frontend"]
, 2 :. 2 :. "API" :. Project_status_active :. pgArr' ["backend"]
, 3 :. 3 :. "Docs" :. Project_status_draft :. pgArr' []
, 4 :. 4 :. "Legacy" :. Project_status_archived :. pgArr' ["backend"] ]
_ <- insertSch_ (MyAnn "tasks") conn $ rowsOf @TaskIn
[ 1 :. 1 :. "Design" :. 10 :. Just (Payload 3) :. pgArr' [2, 3]
, 1 :. 2 :. "Frontend" :. 30 :. Just (Payload 8) :. pgArr' [3]
, 1 :. 3 :. "Polish" :. 100 :. Nothing :. pgArr' []
, 2 :. 1 :. "Schema" :. 20 :. Just (Payload 5) :. pgArr' [1]
, 2 :. 2 :. "Endpoints" :. 40 :. Nothing :. pgArr' [1, 4]
, 3 :. 1 :. "Outline" :. 50 :. Nothing :. pgArr' [] ]
All examples are run by the same helper function run. It prints the result
rows one per line, then the query parameters and, separately, the formatted
SQL:
-- | Run a query, print the rows one per line and the query parameters,
-- then the formatted SQL.
run :: Show r => String -> IO ([r], (Text, [SomeToField])) -> IO ()
run name act = do
sql <- section name $ do
(rows, (sql, params)) <- act
mapM_ print rows
putStrLn $ "params: " <> show params
pure sql
sqlSection name [sql]
Result shape¶
Let us start with a query without any conditions. We read projects together with their owners and tasks:
type Owner = "name" := Text
data Task = Task { seq :: Int32, title :: Text, priority :: Int32 }
deriving (Show, Generic)
data Project = Project
{ title :: Text
, status :: ProjectStatus
, projectOwner :: Owner
, tasksProjectIdFkey :: [Task]
} deriving (Show, Generic)
selectSch (MyAnn "projects") @Project conn qpEmpty
Project {title = "Website", status = Project_status_active, projectOwner = "name" =: "Alice", tasksProjectIdFkey = [Task {seq = 1, title = "Design", priority = 10},Task {seq = 2, title = "Frontend", priority = 30},Task {seq = 3, title = "Polish", priority = 100}]}
Project {title = "API", status = Project_status_active, projectOwner = "name" =: "Bob", tasksProjectIdFkey = [Task {seq = 1, title = "Schema", priority = 20},Task {seq = 2, title = "Endpoints", priority = 40}]}
Project {title = "Docs", status = Project_status_draft, projectOwner = "name" =: "Carol", tasksProjectIdFkey = [Task {seq = 1, title = "Outline", priority = 50}]}
Project {title = "Legacy", status = Project_status_archived, projectOwner = "name" =: "Dave", tasksProjectIdFkey = []}
params: []
Formatted SQL
select t0.title "title",
t0.status "status",
jsonb_build_object('name',t1.name) "projectOwner",
array_to_json(array(
select jsonb_build_object('seq',t2.seq,'title',t2.title,'priority',t2.priority)
from tut.tasks t2
where t2.project_id=t0.id)) "tasksProjectIdFkey"
from tut.projects t0
join tut.users t1 on t0.owner_id=t1.id;
Two fields of the type Project describe relations. tasksProjectIdFkey is,
as in the chapter Quick start, the foreign key from tasks to projects: for a project it
leads to child rows, so the field is a list. projectOwner goes the other way:
the key projects_owner_id_fkey points from projects to users, so a
project has exactly one owner, and the field is a single row of type Owner.
We made up the name projectOwner ourselves. The renamer MyRenamer from
the chapter Core workflow translates it into the real name of the foreign key:
data MyRenamer :: Renamer
type family MyRenamerImpl (s :: Symbol) :: Symbol where
MyRenamerImpl "projectOwner" = "projects_owner_id_fkey"
MyRenamerImpl "manager" = "users_manager_id_fkey"
MyRenamerImpl "subordinates" = "users_manager_id_fkey"
MyRenamerImpl s = CamelToSnake s
type instance ApplyRenamer MyRenamer s = MyRenamerImpl s
Note that Owner is not a record but a PgTag: type Owner = "name" := Text.
The two notations can be mixed freely. A field of a Generic record can be a
PgTag row, a list of them or a Maybe, and a record, in turn, can be part of
a :. chain, as in the chapter Core workflow. For a small row of one or two fields you do not
have to define a separate record.
The SQL shows how pg-schema builds such a query. The parent row is attached
with a join, and child rows are gathered by a subquery into a JSON array, so
the whole result is read by one query.
If a foreign key allows NULL, there may be no parent. Then the field must
have a Maybe type, and a left outer join is used instead of a join. A good
example is the column users.manager_id: it refers to the same table users.
The same foreign key can be followed both ways: to the manager (one row or
Nothing) and to the subordinates (a list). pg-schema decides the direction
from the field type, and the renamer lets us give both fields clear names,
manager and subordinates:
data Person = Person
{ name :: Text
, manager :: Maybe Owner
, subordinates :: [Owner]
} deriving (Show, Generic)
selectSch (MyAnn "users") @Person conn qpEmpty
Person {name = "Alice", manager = Nothing, subordinates = ["name" =: "Bob","name" =: "Carol"]}
Person {name = "Bob", manager = Just ("name" =: "Alice"), subordinates = ["name" =: "Dave"]}
Person {name = "Carol", manager = Just ("name" =: "Alice"), subordinates = []}
Person {name = "Dave", manager = Just ("name" =: "Bob"), subordinates = []}
params: []
Formatted SQL
select t0.name "name",
case when t1.name is null then null else jsonb_build_object('name',t1.name) end "manager",
array_to_json(array(
select jsonb_build_object('name',t2.name)
from tut.users t2
where t2.manager_id=t0.id)) "subordinates"
from tut.users t0
left outer join tut.users t1 on t0.manager_id=t1.id;
Conditions¶
Conditions are set with the function qWhere inside qRoot. For comparing
with a parameter there are the operators =?, <?, >?, <=? and >=?, and
for matching strings against a pattern ~=? (like) and ~~? (ilike).
Conditions are combined with the operators &&& and |||, and pnot negates
them. &&& binds tighter than |||, like and and or in SQL, so no extra
parentheses are needed:
selectSch (MyAnn "projects") @("title" := Text :. "status" := ProjectStatus) conn $ qRoot do
qWhere $ "status" =? Project_status_active &&& "title" ~~? ("%site%" :: Text)
||| pnot ("status" =? Project_status_active) &&& pinArr "id" [3, 4 :: Int64]
"title" =: "Website" :. "status" =: Project_status_active
"title" =: "Docs" :. "status" =: Project_status_draft
"title" =: "Legacy" :. "status" =: Project_status_archived
params: [SomeToField Project_status_active,SomeToField "%site%",SomeToField Project_status_active,SomeToField (PGArray {fromPGArray = [3,4]})]
Formatted SQL
select t0.title "title",
t0.status "status"
from tut.projects t0
where (t0.status = ? and t0.title ilike ? or not (t0.status = ?) and t0.id = any(?::int8[]));
Here we selected active projects whose title contains "site" and, besides
them, inactive projects with id 3 or 4. pinArr checks that a value is in a
list. The list may be empty, and then the condition is simply false. Note that
the column id is used in the condition, although it is not in the result:
conditions may refer to any column of the table.
Columns that allow NULL deserve special attention. To check that a value is
missing, use pnull. To compare with a value that may itself be Nothing,
there is the operator =??: it turns into is not distinct from, and
Nothing matches NULL with it. Another way to check membership in a list is
pin, which takes a non-empty list (NonEmpty):
selectSch (MyAnn "users") @("name" := Text :. "email" := Maybe Text) conn $ qRoot do
qWhere $ pnull "email" ||| "email" =?? Just ("bob@example.com" :: Text)
||| pin "name" ("Dave" :| ["Eve" :: Text])
"name" =: "Bob" :. "email" =: Just "bob@example.com"
"name" =: "Carol" :. "email" =: Nothing
"name" =: "Dave" :. "email" =: Just "dave@example.com"
params: [SomeToField (Just "bob@example.com"),SomeToField (PGArray {fromPGArray = ["Dave","Eve"]})]
Formatted SQL
select t0.name "name",
t0.email "email"
from tut.users t0
where ((t0.email is null or t0.email is not distinct from ?) or t0.name = any(?::text[]));
The types of values in conditions are checked as strictly as the result fields.
If a column allows NULL, the value it is compared with must be Maybe too.
For example, "managerId" =? (1 :: Int64) does not compile:
• You have to use Maybe for nullable fields
Table: NameNS "tut" "users"
DB Field name: "manager_id"
DB Field type: NameNS "pg_catalog" "int8"
Haskell Field type: Int64
The right way is "managerId" =? Just (1 :: Int64). Note that the message says
manager_id: names in conditions, like result fields, go through the renamer,
so you can write them either in camelCase or as they are named in the database.
Result branches: qPath¶
Conditions, ordering and row limits can be set not only for the root table but
for any branch of the result. That is what qPath with a relation name is for:
inside it everything refers to the table at the other end of the relation. In
the next example we take only active projects, order them by title in reverse,
and keep one task for each project — the one with the highest priority among
those with a priority below 50 (ascf and descf order by a field ascending
and descending):
selectSch (MyAnn "projects") @Project conn $ qRoot do
qWhere $ "status" =? Project_status_active
qOrderBy [descf "title"]
qPath "tasksProjectIdFkey" do
qWhere $ "priority" <? (50 :: Int32)
qOrderBy [descf "priority"]
qLimit 1
Project {title = "Website", status = Project_status_active, projectOwner = "name" =: "Alice", tasksProjectIdFkey = [Task {seq = 2, title = "Frontend", priority = 30}]}
Project {title = "API", status = Project_status_active, projectOwner = "name" =: "Bob", tasksProjectIdFkey = [Task {seq = 2, title = "Endpoints", priority = 40}]}
params: [SomeToField 50,SomeToField Project_status_active]
Formatted SQL
select t0.title "title",
t0.status "status",
jsonb_build_object('name',t1.name) "projectOwner",
array_to_json(array(
select jsonb_build_object('seq',t2.seq,'title',t2.title,'priority',t2.priority)
from tut.tasks t2
where t2.project_id=t0.id and t2.priority < ?
order by t2.priority Desc nulls last
limit 1)) "tasksProjectIdFkey"
from tut.projects t0
join tut.users t1 on t0.owner_id=t1.id
where t0.status = ?
order by t0.title Desc nulls last;
The conditions on the branch go into the subquery that gathers the tasks, so
they affect only the contents of the list of tasks, not which projects get into
the result. A project without matching tasks stays in the result with an empty
list. That is how child branches behave. With a parent branch it is different:
the parent is attached with a join, so conditions set in a qPath to it (for
example, qPath "projectOwner") go into the where of the main query and
filter the root rows.
Branch conditions are tied to a result field
As we saw in the chapter Quick start, conditions, ordering and limits from qPath apply
to the result field with the same relation name. If the type has no such
field, they silently do not get into the query. For a parent branch this
means that the filter on root rows disappears together with the field.
Filtering rows by related tables: pchild and pparent¶
Sometimes you need the opposite: to select the rows themselves by what is in a
related table. For example, find the projects that have a task with a priority
below 20. That is what the condition pchild is for. It takes the name of the
relation together with the database schema, parameters for selecting child rows
and a condition on them. The relation name goes through the renamer as
everywhere, so we write it the same way as in qPath: "tasksProjectIdFkey".
type Tut name = "tut" ->> name
selectSch (MyAnn "projects") @("title" := Text) conn $ qRoot do
qWhere $ pchild (Tut "tasksProjectIdFkey") defTabParam ("priority" <? (20 :: Int32))
"title" =: "Website"
params: [SomeToField 20]
Formatted SQL
select t0.title "title"
from tut.projects t0
where exists (
select 1
from (
select *
from tut.tasks t0q1
where t0q1.project_id = t0.id) t0q1
where t0q1.priority < ?);
The relation name here is a type: pchild, like selectSch with its
annotation, takes it as a visible type argument. You cannot write it right in
the call as "tut" ->> "tasksProjectIdFkey": the operator ->> also exists at
the value level (it builds names in ordinary code), and GHC reads such an
expression as a value. The type synonym Tut removes this ambiguity. pchild
turns into exists with a subquery. The selection parameters defTabParam
mean "all child rows". They can be narrowed: TabParam holds ordering
(order) and limit/offset (lo, a value of type LO { limit, offset };
lo1 is limit 1). This way you can express the condition "among the first N
children". Let us find the projects whose task with the highest priority value
has a priority below 50:
selectSch (MyAnn "projects") @("title" := Text) conn $ qRoot do
qWhere $ pchild (Tut "tasksProjectIdFkey")
defTabParam { order = [descf "priority"], lo = lo1 }
("priority" <? (50 :: Int32))
"title" =: "API"
params: [SomeToField 50]
Formatted SQL
select t0.title "title"
from tut.projects t0
where exists (
select 1
from (
select *
from tut.tasks t0q1
where t0q1.project_id = t0.id
order by t0q1.priority Desc nulls last
limit 1) t0q1
where t0q1.priority < ?);
For "Website" the first task in this order is "Polish" with priority 100, for "Docs" it is "Outline" with priority 50, so only "API" is left in the result.
pparent works the other way: it filters rows by their parent. Let us find the
tasks of active projects with a priority of at least 30:
selectSch (MyAnn "tasks") @("title" := Text :. "priority" := Int32) conn $ qRoot do
qWhere $ pparent (Tut "tasksProjectIdFkey") ("status" =? Project_status_active)
&&& "priority" >=? (30 :: Int32)
qOrderBy [ascf "priority"]
"title" =: "Frontend" :. "priority" =: 30
"title" =: "Endpoints" :. "priority" =: 40
"title" =: "Polish" :. "priority" =: 100
params: [SomeToField Project_status_active,SomeToField 30]
Formatted SQL
select t0.title "title",
t0.priority "priority"
from tut.tasks t0
where exists (
select 1
from (
select *
from tut.projects t0q1
where t0q1.id = t0.project_id) t0q1
where t0q1.status = ?) and t0.priority >= ?
order by t0.priority Asc nulls last;
The difference between pchild/pparent and qPath matters. pchild and
pparent decide which rows get into the result, but do not add the related
rows themselves to the result. A qPath to a child branch, on the contrary,
does not affect which root rows are selected but defines what ends up in the
nested list. With a branch to a parent, as we saw above, it is different: the
parent is attached with a join, and conditions in a qPath to it filter the
root rows just like pparent. It is convenient to decide first which rows you
need at the root, and then tune the contents of the child branches separately.
Ordering, limit, offset and distinct¶
We have already met qOrderBy: it takes a list of fields, each with an order
direction (ascf ascending, descf descending). qLimit and qOffset limit
the number of rows. Let us take the second and third tasks by descending
priority:
selectSch (MyAnn "tasks") @("title" := Text :. "priority" := Int32) conn $ qRoot do
qOrderBy [descf "priority"]
qLimit 2
qOffset 1
"title" =: "Outline" :. "priority" =: 50
"title" =: "Endpoints" :. "priority" =: 40
params: []
Formatted SQL
select t0.title "title",
t0.priority "priority"
from tut.tasks t0
order by t0.priority Desc nulls last
limit 2
offset 1;
qDistinct removes duplicate rows. For example, this is how to find out which
project statuses occur in the database:
selectSch (MyAnn "projects") @("status" := ProjectStatus) conn $ qRoot do
qDistinct
qOrderBy [ascf "status"]
"status" =: Project_status_draft
"status" =: Project_status_active
"status" =: Project_status_archived
params: []
Formatted SQL
select distinct t0.status "status"
from tut.projects t0
order by t0.status Asc nulls last;
The statuses are ordered as the values are declared in the enum
('draft', 'active', 'archived' in tutorial/sql/00-create-schema.sql), not
alphabetically. qDistinctOn keeps one row for each value of the given fields,
and the ordering decides which row is kept. Let us keep, for each project, one
task with the lowest priority:
selectSch (MyAnn "tasks") @("projectId" := Int64 :. "title" := Text :. "priority" := Int32) conn $ qRoot do
qDistinctOn [ascf "projectId"]
qOrderBy [ascf "priority"]
"projectId" =: 1 :. ("title" =: "Design" :. "priority" =: 10)
"projectId" =: 2 :. ("title" =: "Schema" :. "priority" =: 20)
"projectId" =: 3 :. ("title" =: "Outline" :. "priority" =: 50)
params: []
Formatted SQL
select distinct on (t0.project_id) t0.project_id "projectId",
t0.title "title",
t0.priority "priority"
from tut.tasks t0
order by t0.project_id Asc nulls last,t0.priority Asc nulls last;
PostgreSQL requires the fields of distinct on to come first in order by,
and qDistinctOn adds them there itself.
Since the parent is attached with a join, its fields can be used in
distinct on and order by too, mixed with the fields of the root. Each field
is given inside the qPath of the table it belongs to, and the order of fields
in the query follows the order of the calls. Let us keep, for each combination
of task number and status of its project, one task — the one with the highest
priority:
selectSch (MyAnn "tasks")
@( "seq" := Int32 :. "title" := Text :. "priority" := Int32
:. "tasksProjectIdFkey" := ("title" := Text :. "status" := ProjectStatus) )
conn $ qRoot do
qDistinctOn [ascf "seq"]
qPath "tasksProjectIdFkey" do
qDistinctOn [ascf "status"]
qOrderBy [descf "priority"]
"seq" =: 1 :. ("title" =: "Outline" :. ("priority" =: 50 :. "tasksProjectIdFkey" =: ("title" =: "Docs" :. "status" =: Project_status_draft)))
"seq" =: 1 :. ("title" =: "Schema" :. ("priority" =: 20 :. "tasksProjectIdFkey" =: ("title" =: "API" :. "status" =: Project_status_active)))
"seq" =: 2 :. ("title" =: "Endpoints" :. ("priority" =: 40 :. "tasksProjectIdFkey" =: ("title" =: "API" :. "status" =: Project_status_active)))
"seq" =: 3 :. ("title" =: "Polish" :. ("priority" =: 100 :. "tasksProjectIdFkey" =: ("title" =: "Website" :. "status" =: Project_status_active)))
params: []
Formatted SQL
select distinct on (t0.seq,t1.status) t0.seq "seq",
t0.title "title",
t0.priority "priority",
jsonb_build_object('title',t1.title,'status',t1.status) "tasksProjectIdFkey"
from tut.tasks t0
join tut.projects t1 on t0.project_id=t1.id
order by t0.seq Asc nulls last,t1.status Asc nulls last,t0.priority Desc nulls last;
Note that for a task the field tasksProjectIdFkey is the single row of its
project, while for a project the field with the same name is the list of its
tasks. The relation name is the same, but the meaning depends on the table we
start from.
The query got distinct on (t0.seq, t1.status) and
order by t0.seq, t1.status, t0.priority desc: a field of the root, a field of
the parent and again a field of the root. Ordering without distinct on works
the same way, by calling qOrderBy at the root and inside a qPath to the
parent.
All these functions work in child branches, as we saw with qLimit in the
section about qPath. But qLimit, qOffset and qDistinct make no sense in
a branch to a parent, since a row has only one parent, and the compiler does
not accept them there.
A table related to itself¶
For a foreign key that refers to its own table, qPath does not fit. qPath
decides the direction from the relation: whether it leads from the current
table or into it. For the relation users_manager_id_fkey both are true, and
the name alone does not tell whether we go to the manager or to the
subordinates. (The result type has no such ambiguity: there the direction is
seen from the field type — a list or a single row.) The compiler says so:
• qPath cannot be used for self-reference relation.
Use qPathFromHere or qPathToHere.
Table: NameNS "tut" "users"
Relation: "users_manager_id_fkey"
Instead of qPath, use qPathFromHere here (by the key of the current row,
that is, to the manager) and qPathToHere (to the rows that refer to the
current one, that is, to the subordinates). Let us select users without a
manager or with the manager id = 1, and keep in the list of subordinates only
those whose name contains the letter "o":
selectSch (MyAnn "users") @Person conn $ qRoot do
qWhere $ pnull "managerId" ||| "managerId" =? Just (1 :: Int64)
qOrderBy [ascf "name"]
qPathToHere "subordinates" do
qWhere $ "name" ~~? ("%o%" :: Text)
Person {name = "Alice", manager = Nothing, subordinates = ["name" =: "Bob","name" =: "Carol"]}
Person {name = "Bob", manager = Just ("name" =: "Alice"), subordinates = []}
Person {name = "Carol", manager = Just ("name" =: "Alice"), subordinates = []}
params: [SomeToField "%o%",SomeToField (Just 1)]
Formatted SQL
select t0.name "name",
case when t1.name is null then null else jsonb_build_object('name',t1.name) end "manager",
array_to_json(array(
select jsonb_build_object('name',t2.name)
from tut.users t2
where t2.manager_id=t0.id and t2.name ilike ?)) "subordinates"
from tut.users t0
left outer join tut.users t1 on t0.manager_id=t1.id
where (t0.manager_id is null or t0.manager_id = ?)
order by t0.name Asc nulls last;
Bob's subordinate Dave is not in the list: his name has no letter "o".
The names manager and subordinates here are just two names of the same
foreign key, and the direction is set not by the name but by the function. If we
wrote qPathFromHere "manager" with the same condition, it would apply to the
manager and, as with any parent branch, filter the users themselves: only those
whose manager's name contains "o" would remain. On our data there are none, and
the result would be empty.
Aggregates¶
Aggregate functions are also set by the result type. A field of type
Aggr' ACount Int64 becomes count(*), and Aggr' AMax Int32 becomes max
over the column named like the field. The other fields without aggregates
become grouping keys, and group by is added automatically:
selectSch (MyAnn "tasks")
@("projectId" := Int64 :. "cnt" := Aggr' ACount Int64 :. "priority" := Aggr' AMax Int32)
conn $ qRoot do
qOrderBy [ascf "projectId"]
"projectId" =: 1 :. ("cnt" =: Aggr' {unAggr' = 3} :. "priority" =: Aggr' {unAggr' = 100})
"projectId" =: 2 :. ("cnt" =: Aggr' {unAggr' = 2} :. "priority" =: Aggr' {unAggr' = 40})
"projectId" =: 3 :. ("cnt" =: Aggr' {unAggr' = 1} :. "priority" =: Aggr' {unAggr' = 50})
params: []
Formatted SQL
select t0.project_id "projectId",
count(*) "cnt",
max(t0.priority) "priority"
from tut.tasks t0
group by project_id
order by t0.project_id Asc nulls last;
Aggr' is meant for queries with grouping: each group has at least one row, so
max over a mandatory column cannot return NULL, and Maybe is not needed.
Without grouping, however, max over an empty table returns NULL. For such
queries there is Aggr: it requires Maybe for all functions except count.
The compiler does not forbid Aggr' without grouping, but then a query to an
empty table fails when reading the result, so without grouping it is better to
always use Aggr:
selectSch (MyAnn "tasks")
@("cnt" := Aggr ACount Int64 :. "priority" := Aggr AMax (Maybe Int32))
conn qpEmpty
"cnt" =: Aggr {unAggr = 6} :. "priority" =: Aggr {unAggr = Just 100}
params: []
Formatted SQL
select count(*) "cnt",
max(t0.priority) "priority"
from tut.tasks t0;
The functions count, min, max, sum and avg are supported.
jsonb and arrays¶
A jsonb column can be read into any type with the FromJSON and ToJSON
instances from aeson. Where postgresql-simple itself parses or passes
values, you also need FromField (reading at the root of a query) and
ToField (single-table writes, such as the insertSch that fills this
chapter's data). They are easiest to define with fromJSONField and
toJSONField. The column tasks.payload holds the estimate of a task:
newtype Payload = Payload { estimate :: Int } deriving (Show, Generic)
instance FromJSON Payload
instance ToJSON Payload
instance FromField Payload where fromField = fromJSONField
instance ToField Payload where toField = toJSONField
Arrays are read into the type PgArr, which we met in the chapter Single-table DML. Its elements
have type Maybe, because a PostgreSQL array may contain NULL, so in the
output the watchers look like [Just 2, Just 3]:
selectSch (MyAnn "tasks")
@("title" := Text :. "payload" := Maybe Payload :. "watchers" := PgArr Int64)
conn $ qRoot do
qWhere $ pnot (pnull "payload")
"title" =: "Design" :. ("payload" =: Just (Payload {estimate = 3}) :. "watchers" =: PgArr {unPgArr = [Just 2,Just 3]})
"title" =: "Frontend" :. ("payload" =: Just (Payload {estimate = 8}) :. "watchers" =: PgArr {unPgArr = [Just 3]})
"title" =: "Schema" :. ("payload" =: Just (Payload {estimate = 5}) :. "watchers" =: PgArr {unPgArr = [Just 1]})
params: []
Formatted SQL
select t0.title "title",
t0.payload "payload",
t0.watchers "watchers"
from tut.tasks t0
where not (t0.payload is null);
The way out: UnsafeCol, pUnsafeCond and UnsafeOrd¶
A lot can be written with pg-schema, but not everything. It is a pity when a
big query over twenty tables does not fit the scheme because one condition has
to compare the sum of two fields. For such cases there are "unsafe" tools: they
let you put a piece of SQL into a query, while the compiler still checks
everything else. UnsafeCol describes a result field as an arbitrary SQL
expression: the type lists the columns substituted for the ? marks, the
expression itself and the type of the result. The ? marks here stand for
columns, not query parameters. The column names are checked, but the expression
and whether its result matches the type (Int32) are not: an error in them
shows up only at run time. pUnsafeCond sets a condition as arbitrary text,
and UnsafeOrd an ordering expression together with its direction. Both build
text in a small monad CondMonad, where qual @"field" returns the name of a
column of the current table with the right alias. qual does not check the
field name, so a typo in it also shows up only at run time:
selectSch (MyAnn "tasks")
@("title" := Text :. "titleLength" := UnsafeCol '["title"] "length(?)" Int32)
conn $ qRoot do
qWhere $ pUnsafeCond do
sq <- qual @"seq"
priority <- qual @"priority"
tell [SomeToField (40 :: Int32)]
pure $ sq <> " + " <> priority <> " > ?"
qOrderBy
[ UnsafeOrd do
title <- qual @"title"
pure ("length(" <> title <> ")", Desc) ]
"title" =: "Endpoints" :. "titleLength" =: UnsafeCol ['title'] 'length(?)' 9
"title" =: "Outline" :. "titleLength" =: UnsafeCol ['title'] 'length(?)' 7
"title" =: "Polish" :. "titleLength" =: UnsafeCol ['title'] 'length(?)' 6
params: [SomeToField 40]
Formatted SQL
select t0.title "title",
length(t0.title) "titleLength"
from tut.tasks t0
where t0.seq + t0.priority > ?
order by length(t0.title) Desc nulls last;
Here we selected the tasks whose number plus priority is greater than 40,
computed the length of the title for each, and ordered the tasks by that
length. The programmer, not the compiler, is responsible for such expressions,
so use them only when the regular tools really are not enough. A value from the
program, like the number 40 in our condition, is better passed as a parameter
than pasted into the text: tell adds it to the parameter list, and a ?
stands in its place in the text. This keeps SQL injection out of the query.
Summary¶
- The result type defines child lists (
[T]), a parent (T) and an optional parent (Maybe T). - Conditions are built from the operators
=?,~~?,&&&,|||,pnot,pin,pinArr,pnull,=??. pchildandpparentfilter root rows, aqPathto a child branch defines its contents, and aqPathto a parent filters root rows too.- Ordering can be set at any level, and
limit,offsetanddistinctat the root and in child branches. - For a table related to itself, use
qPathFromHereandqPathToHere. - Aggregates are set by the types
AggrandAggr'.
What next¶
We can now read data in any shape we need. Writing data into several related tables at once — projects together with tasks and events — is also done in one operation, and that is the subject of the chapter Tree DML. The full list of rules for result types is in the reference.