jade-sql 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +16 -10
- data/docs/building.md +398 -96
- data/docs/running.md +37 -17
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +655 -44
- data/lib/jade-sql/compiler/assignable.rb +146 -0
- data/lib/jade-sql/compiler/columns.rb +220 -0
- data/lib/jade-sql/compiler/errors.rb +82 -0
- data/lib/jade-sql/compiler/selectable.rb +76 -0
- data/lib/jade-sql/compiler.rb +34 -0
- data/lib/jade-sql/runtime.rb +26 -12
- data/lib/jade-sql/schema_drift.rb +115 -0
- data/lib/jade-sql/sql/expr.jd +65 -0
- data/lib/jade-sql/sql/json.jd +154 -0
- data/lib/jade-sql/sql/query.jd +284 -92
- data/lib/jade-sql/sql/write.jd +625 -0
- data/lib/jade-sql/sql.jd +339 -89
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +8 -7
- metadata +16 -8
- data/lib/jade-sql/sql/mutation.jd +0 -329
data/docs/building.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Building SQL
|
|
2
2
|
|
|
3
3
|
Generate a typed schema from your database, then build queries and
|
|
4
|
-
|
|
4
|
+
writes against it. To run what you build, see [running.md](running.md).
|
|
5
5
|
|
|
6
6
|
## Generate `schema.jd` from `db/structure.sql`
|
|
7
7
|
|
|
@@ -31,12 +31,36 @@ Type map: `bigint`/`integer`/`smallint` → `Int`, `numeric`/`decimal` →
|
|
|
31
31
|
`Decimal` (jade's stdlib exact decimal), `double precision`/`real` →
|
|
32
32
|
`Float`, `varchar`/`text`/`char` → `String`, `boolean` → `Bool`,
|
|
33
33
|
`jsonb`/`json` → `Decode.Value`, `date` → `Calendar.Date`, `timestamp` →
|
|
34
|
-
`Clock.Instant`, `uuid` → `Uuid` (from `Sql.Uuid`)
|
|
35
|
-
loudly with the table+column name.
|
|
34
|
+
`Clock.Instant`, `uuid` → `Uuid` (from `Sql.Uuid`), `citext`/`inet`/`cidr`/
|
|
35
|
+
`macaddr` → `String`. Unknown types fail loudly with the table+column name.
|
|
36
|
+
|
|
37
|
+
A `citext` column compares case-insensitively, and its Jade type does not say
|
|
38
|
+
so — `Expr(String)` is what an ordinary `text` column gets too. That is the
|
|
39
|
+
database's own opacity rather than something the schema drops: nothing in a
|
|
40
|
+
query tells you either. It matters in one place. `where(c.email |> eq(input))`
|
|
41
|
+
is case-insensitive, because Postgres does the comparing; the same comparison
|
|
42
|
+
written in Jade over a fetched row is not. Compare in the query, or fold the
|
|
43
|
+
case yourself once it has left.
|
|
36
44
|
|
|
37
45
|
`numeric`/`decimal` map to the stdlib `Decimal` — an exact base-10 value
|
|
38
46
|
(`coefficient * 10^exponent`), never `Float`, so no precision is lost.
|
|
39
47
|
Genuine floating-point columns (`double precision`/`real`) map to `Float`.
|
|
48
|
+
A `CREATE TYPE … AS ENUM` becomes a union, and its columns are typed by it:
|
|
49
|
+
|
|
50
|
+
```jade
|
|
51
|
+
-- CREATE TYPE visit_status AS ENUM ('scheduled', 'in_progress', 'done');
|
|
52
|
+
|
|
53
|
+
type VisitStatus
|
|
54
|
+
= Scheduled
|
|
55
|
+
| InProgress
|
|
56
|
+
| Done
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Nullary unions derive `Encodable` and `Decodable` with the variant name in
|
|
60
|
+
snake_case, which is the label Postgres stores — so the codec is free and
|
|
61
|
+
`eq(v.status, "schedulled")` stops compiling. Before this, an enum
|
|
62
|
+
column failed generation outright with `Unknown SQL type`.
|
|
63
|
+
|
|
40
64
|
`bytea` isn't mapped yet, though jade's `Bytes` is the natural target. See
|
|
41
65
|
jade-lang's `Decimal` for the full API (`of`/`scaled`/`parse`, arithmetic,
|
|
42
66
|
`round`, `to_i`/`to_float`).
|
|
@@ -45,18 +69,103 @@ For each table, the generator emits:
|
|
|
45
69
|
|
|
46
70
|
```jade
|
|
47
71
|
struct PatientsCols = { id: Expr(Int), name: Expr(String), ... }
|
|
48
|
-
struct
|
|
72
|
+
struct PatientsLeftCols = { id: Expr(Maybe(Int)), name: Expr(Maybe(String)), ... }
|
|
49
73
|
struct PatientsRow = { id: Int, name: String, ... }
|
|
50
74
|
|
|
51
|
-
def patients -> Table(PatientsCols,
|
|
75
|
+
def patients -> Table(PatientsCols, PatientsLeftCols)
|
|
52
76
|
table("patients", "patients", ..., ["id"])
|
|
53
77
|
end
|
|
78
|
+
|
|
79
|
+
def patients_pk -> Pk(PatientsCols, Int)
|
|
80
|
+
pk("pkey", ["id"], patients_pk_values)
|
|
81
|
+
end
|
|
54
82
|
```
|
|
55
83
|
|
|
56
84
|
Strict cols mirror NOT NULL constraints; the maybe version wraps every
|
|
57
85
|
field in `Maybe` for left-join projections. The default alias is the
|
|
58
86
|
table name; override per-call with `aliased` (see joins below).
|
|
59
87
|
|
|
88
|
+
Every unique index becomes a name too, from both spellings: a table-level
|
|
89
|
+
`UNIQUE (...)` and a standalone `CREATE UNIQUE INDEX`.
|
|
90
|
+
|
|
91
|
+
```jade
|
|
92
|
+
def users_email_key -> Unique(UsersCols)
|
|
93
|
+
unique("users_email_key", ["email"])
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`matching` builds the predicate for a read by that index, and the key type
|
|
98
|
+
comes from the index, so a composite cannot be given in the wrong order:
|
|
99
|
+
|
|
100
|
+
```jade
|
|
101
|
+
where(matching(users_email_key, "ada@example.com"))
|
|
102
|
+
# WHERE email = ?
|
|
103
|
+
|
|
104
|
+
where(matching(users_tenant_email_key, (7, "ada@example.com")))
|
|
105
|
+
# WHERE tenant_id = ? AND email = ?
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Pair it with `Sql.Query.fetch_at_most_one`, which takes no row as an answer
|
|
109
|
+
rather than an error. More than one is still `TooManyRows`: nothing is dropped
|
|
110
|
+
to make the type fit, and the index is what makes that case unreachable.
|
|
111
|
+
|
|
112
|
+
`UniqueViolation` carries the constraint name Postgres reports, so a write
|
|
113
|
+
routes its own failure:
|
|
114
|
+
|
|
115
|
+
```jade
|
|
116
|
+
case err
|
|
117
|
+
in UniqueViolation(i) then i == users_email_key.name ? EmailTaken : Other
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Rename the index, regenerate, and the call site stops compiling rather than
|
|
122
|
+
quietly never matching again, which is what a string literal there would do. `Unique(c)` is phantom in the column struct, so
|
|
123
|
+
an index cannot be used with a table it is not on.
|
|
124
|
+
|
|
125
|
+
A partial index is skipped: it constrains only the rows its `WHERE` matches,
|
|
126
|
+
so a conflict target built from it is not the one the database enforces.
|
|
127
|
+
|
|
128
|
+
`patients_pk` names the table's primary key. `Pk(c, k)` is phantom in the
|
|
129
|
+
column struct, so a key can only be used with the table it came from, and
|
|
130
|
+
carries the key's own type — `Int` here, a tuple for a composite key,
|
|
131
|
+
which a generated helper spreads across its columns in DDL order. A table
|
|
132
|
+
with no `PRIMARY KEY` in `structure.sql` gets no `_pk` and is keyed by
|
|
133
|
+
`NoKey`, which has no constructor you can reach — so `update` and `delete`
|
|
134
|
+
are unavailable on it, and `update_all`/`delete_all` are how you write to it.
|
|
135
|
+
|
|
136
|
+
Every foreign key in `structure.sql` becomes a field on the table's `on`
|
|
137
|
+
record, from both ends — one constraint, two ways to read it:
|
|
138
|
+
|
|
139
|
+
```jade
|
|
140
|
+
p <- from(patients)
|
|
141
|
+
a <- join(appointments, p |> patients.on.appointments)
|
|
142
|
+
ph <- left_join(phones, p |> patients.on.phone)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`join` never sees the left side — it builds a query the bind chain composes —
|
|
146
|
+
so the predicate it takes is a function of the joined table's columns alone.
|
|
147
|
+
Each field on the `on` record takes the parent columns and returns exactly
|
|
148
|
+
that, which is why the parent goes in by pipe and the child is left to `join`.
|
|
149
|
+
|
|
150
|
+
The result is an ordinary predicate function, the same thing a hand-written
|
|
151
|
+
`(ph) -> { ... }` is, so extra conditions compose:
|
|
152
|
+
|
|
153
|
+
```jade
|
|
154
|
+
ph <- left_join(phones, (ph) -> {
|
|
155
|
+
ph |> (p |> patients.on.phone) |> and(ph.deleted_at |> is_null)
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
On a left join that distinction matters: the same condition in `where` would
|
|
160
|
+
drop the parent row instead of nulling the child.
|
|
161
|
+
|
|
162
|
+
An outgoing key is named after its column minus `_id`, an incoming one after
|
|
163
|
+
the table it comes from, and a second key between the same pair keeps its
|
|
164
|
+
column name to stay distinct. A nullable foreign key column is
|
|
165
|
+
`Expr(Maybe(a))` while the key it points at is `Expr(a)`, so the generated
|
|
166
|
+
predicate lifts whichever side is not nullable. A table with no foreign keys
|
|
167
|
+
carries `NoJoins`.
|
|
168
|
+
|
|
60
169
|
A column whose name is a Jade keyword (e.g. `type`) gets a trailing
|
|
61
170
|
underscore in the struct field (`type_`) while the SQL column reference
|
|
62
171
|
keeps the real name. For a table with such a column the generator also
|
|
@@ -64,7 +173,7 @@ emits a `<table>_row` projector that aliases every column to its field name
|
|
|
64
173
|
(`SELECT … AS type_`), so reads round-trip without hand-written SQL:
|
|
65
174
|
|
|
66
175
|
```jade
|
|
67
|
-
def entries ->
|
|
176
|
+
def entries -> Select(JournalEntriesRow)
|
|
68
177
|
c <- from(journal_entries)
|
|
69
178
|
journal_entries_row(c)
|
|
70
179
|
end
|
|
@@ -78,8 +187,8 @@ makes a `COUNT(*)` land in a `visits` field).
|
|
|
78
187
|
## Build queries
|
|
79
188
|
|
|
80
189
|
```jade
|
|
81
|
-
import Sql exposing (Selector, eq
|
|
82
|
-
import Sql.Query exposing (
|
|
190
|
+
import Sql exposing (Selector, eq)
|
|
191
|
+
import Sql.Query exposing (Query, field, from, join, select, where)
|
|
83
192
|
import Schema exposing (patients, appointments)
|
|
84
193
|
|
|
85
194
|
struct Visit = {
|
|
@@ -87,14 +196,14 @@ struct Visit = {
|
|
|
87
196
|
reason: String
|
|
88
197
|
}
|
|
89
198
|
|
|
90
|
-
def scheduled_visits ->
|
|
199
|
+
def scheduled_visits -> Select(Visit)
|
|
91
200
|
p <- from(patients)
|
|
92
|
-
a <- join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
201
|
+
a <- join(appointments, (a) -> { p.id |> Expr.eq(a.patient_id) })
|
|
93
202
|
|
|
94
203
|
select(Visit(_, _))
|
|
95
204
|
|> field(p.name)
|
|
96
205
|
|> field(a.reason)
|
|
97
|
-
|> where(a.status |> eq(
|
|
206
|
+
|> where(a.status |> eq("scheduled"))
|
|
98
207
|
end
|
|
99
208
|
```
|
|
100
209
|
|
|
@@ -107,20 +216,26 @@ Notes:
|
|
|
107
216
|
|
|
108
217
|
### Predicates
|
|
109
218
|
|
|
110
|
-
`eq`, `gt`, `gte`, `lt`, `lte` compare
|
|
111
|
-
`is_null` / `is_not_null` take one; `and` joins
|
|
112
|
-
`
|
|
219
|
+
`eq`, `neq`, `gt`, `gte`, `lt`, `lte` compare a column against a value you
|
|
220
|
+
hold and yield `Expr(Bool)`; `is_null` / `is_not_null` take one; `and` joins
|
|
221
|
+
two; `any_of` matches a list. `db_now` is the database clock (`now()`), for
|
|
222
|
+
time comparisons.
|
|
223
|
+
|
|
224
|
+
To compare against something already built instead of a value, the same names
|
|
225
|
+
live in `Sql.Expr`:
|
|
113
226
|
|
|
114
227
|
```jade
|
|
115
|
-
import Sql exposing (
|
|
228
|
+
import Sql exposing (db_now, gte)
|
|
229
|
+
import Sql.Expr as Expr
|
|
116
230
|
|
|
117
|
-
a.starts_at |> gte(
|
|
118
|
-
|
|
231
|
+
a.starts_at |> gte(cutoff) # a.starts_at >= ?
|
|
232
|
+
a.starts_at |> Expr.gte(a.ends_at) # a.starts_at >= a.ends_at
|
|
233
|
+
s.expires_at |> Expr.gt(db_now) # s.expires_at > now()
|
|
119
234
|
```
|
|
120
235
|
|
|
121
|
-
`
|
|
236
|
+
`db_now` is `Expr(Instant)` — the *DB* transaction clock, not the app clock.
|
|
122
237
|
It's the right tool for `WHERE` filters; for `created_at`/`updated_at` use
|
|
123
|
-
`Sql.
|
|
238
|
+
`Sql.Write.timestamped` (below), which uses the app clock like Rails.
|
|
124
239
|
|
|
125
240
|
### Sorting and grouping
|
|
126
241
|
|
|
@@ -129,7 +244,7 @@ It's the right tool for `WHERE` filters; for `created_at`/`updated_at` use
|
|
|
129
244
|
order — e.g. counting visits per patient, busiest first:
|
|
130
245
|
|
|
131
246
|
```jade
|
|
132
|
-
import Sql exposing (
|
|
247
|
+
import Sql exposing (count_all)
|
|
133
248
|
import Sql.Query exposing (group, order, order_desc)
|
|
134
249
|
|
|
135
250
|
struct VisitCount = {
|
|
@@ -137,23 +252,52 @@ struct VisitCount = {
|
|
|
137
252
|
visits: Int
|
|
138
253
|
}
|
|
139
254
|
|
|
140
|
-
def visit_counts ->
|
|
255
|
+
def visit_counts -> Select(VisitCount)
|
|
141
256
|
p <- from(patients)
|
|
142
|
-
a <- join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
257
|
+
a <- join(appointments, (a) -> { p.id |> Expr.eq(a.patient_id) })
|
|
143
258
|
|
|
144
259
|
select(VisitCount(_, _))
|
|
145
260
|
|> field(p.name)
|
|
146
261
|
|> field(count_all)
|
|
147
|
-
|> group(
|
|
262
|
+
|> group(p.name)
|
|
148
263
|
|> order_desc(count_all)
|
|
149
|
-
|> order(
|
|
264
|
+
|> order(p.name)
|
|
150
265
|
end
|
|
151
266
|
# ... GROUP BY p.name ORDER BY COUNT(*) DESC, p.name
|
|
152
267
|
```
|
|
153
268
|
|
|
154
|
-
`
|
|
155
|
-
|
|
156
|
-
|
|
269
|
+
`having(q, predicate)` filters on an aggregate, after `group` has collapsed
|
|
270
|
+
the rows. `where` cannot: it runs before the grouping, so `count_all` has
|
|
271
|
+
nothing to count yet.
|
|
272
|
+
|
|
273
|
+
```jade
|
|
274
|
+
select(Busy(_))
|
|
275
|
+
|> field(v.patient_id)
|
|
276
|
+
|> group(v.patient_id)
|
|
277
|
+
|> having(count_all |> gt(3))
|
|
278
|
+
# ... GROUP BY v.patient_id HAVING COUNT(*) > ?
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`distinct(q)` drops duplicate rows from the whole projected row, which is
|
|
282
|
+
`SELECT DISTINCT` rather than Postgres' `DISTINCT ON`.
|
|
283
|
+
|
|
284
|
+
`exists(q)` and `not_exists(q)` ask whether a related row is there, without
|
|
285
|
+
joining to it and without projecting anything from it. The inner query may
|
|
286
|
+
name the outer query's columns, which is what makes it correlated:
|
|
287
|
+
|
|
288
|
+
```jade
|
|
289
|
+
p <- from(patients)
|
|
290
|
+
|
|
291
|
+
select(Name(_))
|
|
292
|
+
|> field(p.name)
|
|
293
|
+
|> where(exists(from(visits) |> filter((v) -> { v.patient_id |> Expr.eq(p.id) })))
|
|
294
|
+
# ... WHERE EXISTS (SELECT 1 FROM visits v WHERE v.patient_id = p.id)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
They take an unprojected `Query`, since `EXISTS` ignores the select list.
|
|
298
|
+
|
|
299
|
+
`CASE` is not built in — for conditional expressions, fall back to the raw-SQL
|
|
300
|
+
escape hatch (`execute_*`). Basic aggregates (`SUM`, `COUNT`) and the
|
|
157
301
|
null-handling primitive (`coalesce`) are typed; see *Aggregates,
|
|
158
302
|
COALESCE* below.
|
|
159
303
|
|
|
@@ -166,7 +310,7 @@ returned `List(Value)` is unaffected:
|
|
|
166
310
|
```jade
|
|
167
311
|
import Sql.Query exposing(limit, offset)
|
|
168
312
|
|
|
169
|
-
def page(n: Int) ->
|
|
313
|
+
def page(n: Int) -> Select(Visit)
|
|
170
314
|
scheduled_visits
|
|
171
315
|
|> limit(20)
|
|
172
316
|
|> offset(n * 20)
|
|
@@ -182,7 +326,7 @@ The schema's default alias = table name. Override with `aliased`:
|
|
|
182
326
|
|
|
183
327
|
```jade
|
|
184
328
|
p <- from(patients)
|
|
185
|
-
c <- patients |> aliased("c") |> join((c) -> { p.id |> eq(c.parent_id) })
|
|
329
|
+
c <- patients |> aliased("c") |> join((c) -> { p.id |> Expr.eq(c.parent_id) })
|
|
186
330
|
```
|
|
187
331
|
|
|
188
332
|
### Left joins with nullable views
|
|
@@ -191,20 +335,20 @@ c <- patients |> aliased("c") |> join((c) -> { p.id |> eq(c.parent_id) })
|
|
|
191
335
|
|
|
192
336
|
```jade
|
|
193
337
|
p <- from(patients)
|
|
194
|
-
a <- left_join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
195
|
-
# `a` is
|
|
338
|
+
a <- left_join(appointments, (a) -> { p.id |> Expr.eq(a.patient_id) })
|
|
339
|
+
# `a` is AppointmentsLeftCols; field types are Expr(Maybe(String)) etc.
|
|
196
340
|
```
|
|
197
341
|
|
|
198
342
|
For predicates that lift a non-null column into the nullable side,
|
|
199
343
|
`nullable`:
|
|
200
344
|
|
|
201
345
|
```jade
|
|
202
|
-
p.id |> nullable |> eq(a.patient_id) # Expr(Int) → Expr(Maybe(Int))
|
|
346
|
+
p.id |> nullable |> Expr.eq(a.patient_id) # Expr(Int) → Expr(Maybe(Int))
|
|
203
347
|
```
|
|
204
348
|
|
|
205
|
-
### Phantom-type rewrap with `
|
|
349
|
+
### Phantom-type rewrap with `unsafe_cast`
|
|
206
350
|
|
|
207
|
-
`
|
|
351
|
+
`unsafe_cast(e: Expr(a)) -> Expr(b)` widens a column's phantom type — useful
|
|
208
352
|
for projecting a `VARCHAR` column into a typed enum field whose
|
|
209
353
|
`Decodable(b)` instance does the actual parsing at row decode:
|
|
210
354
|
|
|
@@ -215,12 +359,12 @@ struct Appointment = { id: Int, status: Status, ... }
|
|
|
215
359
|
# "scheduled" / "completed" into the variants.
|
|
216
360
|
select(Appointment(_, _, ...))
|
|
217
361
|
|> field(a.id)
|
|
218
|
-
|> field(a.status |>
|
|
362
|
+
|> field(a.status |> unsafe_cast) # Expr(String) → Expr(Status)
|
|
219
363
|
```
|
|
220
364
|
|
|
221
365
|
Same shape as `nullable` — pure phantom-type rewrap, no runtime
|
|
222
366
|
transformation. The runtime decoder (`Decodable(Status)`) is what
|
|
223
|
-
actually converts the column value; `
|
|
367
|
+
actually converts the column value; `unsafe_cast` just teaches the SQL
|
|
224
368
|
builder that the projection is intended. If `Decodable(b)` can't
|
|
225
369
|
parse the column's actual values, the failure surfaces at row
|
|
226
370
|
decode time, not at type check.
|
|
@@ -236,24 +380,80 @@ builder — params stitch in declaration order automatically.
|
|
|
236
380
|
| `sum(Expr(Int)) -> Expr(Maybe(Int))` | `SUM(e)` | `NULL` on empty group → `Maybe`. |
|
|
237
381
|
| `count(Expr(a)) -> Expr(Int)` | `COUNT(e)` | Counts non-null rows for the column. |
|
|
238
382
|
| `count_all -> Expr(Int)` | `COUNT(*)` | Total row count. |
|
|
239
|
-
| `coalesce(Expr(Maybe(a)),
|
|
383
|
+
| `coalesce(Expr(Maybe(a)), a) -> Expr(a)` | `COALESCE(e, ?)` | Drops the `Maybe` with a fallback. |
|
|
240
384
|
| `neg(Expr(Int)) -> Expr(Int)` | `-(e)` | Unary minus. |
|
|
241
385
|
|
|
242
386
|
Worked example — count visits and the most recent visit number,
|
|
243
387
|
coalesced to 0 when a patient has none:
|
|
244
388
|
|
|
245
389
|
```jade
|
|
246
|
-
import Sql exposing (coalesce,
|
|
390
|
+
import Sql exposing (coalesce, count_all, sum)
|
|
247
391
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
392
|
+
def totals -> Select(Totals)
|
|
393
|
+
a <- from(appointments)
|
|
394
|
+
|
|
395
|
+
select(Totals(_, _))
|
|
396
|
+
|> field(count_all)
|
|
397
|
+
|> field(coalesce(sum(a.visit_no), 0))
|
|
398
|
+
end
|
|
399
|
+
# SELECT COUNT(*), COALESCE(SUM(a.visit_no), ?) FROM appointments a
|
|
252
400
|
```
|
|
253
401
|
|
|
254
|
-
For `CASE WHEN
|
|
402
|
+
For `CASE WHEN` and arithmetic, fall back to the raw-`Expr`
|
|
255
403
|
escape hatch until they get a typed builder.
|
|
256
404
|
|
|
405
|
+
### Subqueries
|
|
406
|
+
|
|
407
|
+
`exists` and `not_exists` ask whether a related row is there, `subquery` reads
|
|
408
|
+
a single value out of one, and `in_subquery` matches a column against one a
|
|
409
|
+
subquery selects. All four take an unprojected `Query`, which `rows` builds.
|
|
410
|
+
|
|
411
|
+
Each is named for the SQL it renders. There is no `not_in`: `NOT IN` against a
|
|
412
|
+
subquery yielding a NULL returns no rows at all, so `not_exists` is the form to
|
|
413
|
+
reach for.
|
|
414
|
+
|
|
415
|
+
```jade
|
|
416
|
+
import Sql.Query exposing (
|
|
417
|
+
from,
|
|
418
|
+
in_subquery,
|
|
419
|
+
limit,
|
|
420
|
+
order_desc,
|
|
421
|
+
rows,
|
|
422
|
+
subquery,
|
|
423
|
+
where,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def latest(p: PatientsCols) -> Query(VisitsCols)
|
|
427
|
+
v <- from(visits)
|
|
428
|
+
|
|
429
|
+
rows(visits)
|
|
430
|
+
|> where(v.patient_id |> Expr.eq(p.id))
|
|
431
|
+
|> order_desc(v.seen_on)
|
|
432
|
+
|> limit(1)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
select(Row(_, _))
|
|
436
|
+
|> field(p.id)
|
|
437
|
+
|> field(subquery(latest(p), .seen_on))
|
|
438
|
+
# SELECT p.id, (SELECT v.seen_on FROM visits v WHERE v.patient_id = p.id
|
|
439
|
+
# ORDER BY v.seen_on DESC LIMIT 1) FROM patients p
|
|
440
|
+
|
|
441
|
+
where(p.id |> in_subquery(from(visits), .patient_id))
|
|
442
|
+
# WHERE p.id IN (SELECT v.patient_id FROM visits v)
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`rows(t)` is `select`'s unprojected twin: it starts a query that carries the
|
|
446
|
+
table's columns rather than a projection, so a subquery is written in an
|
|
447
|
+
ordinary bind chain. It names the table rather than borrowing columns, so the
|
|
448
|
+
query it starts always renders its own `FROM` — one that borrowed would read
|
|
449
|
+
the outer query's table instead, which is legal SQL asking a different
|
|
450
|
+
question. Naming a table the chain already bound costs nothing, since a table
|
|
451
|
+
is listed once however many times it is named. The column is picked by a
|
|
452
|
+
function rather than projected, because
|
|
453
|
+
`Select(a)` does not say how many columns it has, and a subquery in a value
|
|
454
|
+
position may only have one. `subquery` returns `Expr(Maybe(a))`, since a
|
|
455
|
+
subquery matching no rows is NULL.
|
|
456
|
+
|
|
257
457
|
### Postgres arrays
|
|
258
458
|
|
|
259
459
|
Typed predicates on `text[]` / `int[]` / `uuid[]` columns. All bind
|
|
@@ -272,21 +472,28 @@ the other side of the operator. No `ARRAY[$1,...,$N]` expansion, no
|
|
|
272
472
|
|
|
273
473
|
Example — filter appointments whose tag set overlaps any selected chip:
|
|
274
474
|
|
|
475
|
+
A predicate written outside a bind chain has no accessors in scope, so take
|
|
476
|
+
them as an argument and hand it to `filter`, which supplies them:
|
|
477
|
+
|
|
275
478
|
```jade
|
|
276
|
-
import Sql exposing (array_overlaps
|
|
479
|
+
import Sql exposing (array_overlaps)
|
|
480
|
+
import Sql.Query exposing (filter, from)
|
|
277
481
|
|
|
278
|
-
def
|
|
279
|
-
array_overlaps(
|
|
482
|
+
def tagged(selected: List(String)) -> (AppointmentsCols -> Expr(Bool))
|
|
483
|
+
(a) -> { array_overlaps(a.tags, selected) }
|
|
280
484
|
end
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
from(appointments) |> filter(tagged(["urgent", "followup"]))
|
|
281
488
|
# WHERE a.tags && ? (param: ["urgent","followup"])
|
|
282
489
|
```
|
|
283
490
|
|
|
284
491
|
`array_length` uses `cardinality(col)` rather than Postgres'
|
|
285
492
|
`array_length(col, 1)` because `cardinality` is non-null (returns 0
|
|
286
493
|
on empty). Filter untagged rows with
|
|
287
|
-
`array_length(
|
|
494
|
+
`array_length(a.tags) |> eq(0)`.
|
|
288
495
|
|
|
289
|
-
|
|
496
|
+
Write ops for partial array updates:
|
|
290
497
|
|
|
291
498
|
| Function | SQL |
|
|
292
499
|
|---------------------------------------------------------------------|------------------------------|
|
|
@@ -294,13 +501,19 @@ Mutation ops for partial array updates:
|
|
|
294
501
|
| `array_remove(Expr(List(a)), a) -> Expr(List(a))` | `array_remove(col, ?)` |
|
|
295
502
|
| `array_concat(Expr(List(a)), Expr(List(a))) -> Expr(List(a))` | `left ‖ right` |
|
|
296
503
|
|
|
504
|
+
`update_all`'s builder receives two records: the column accessors, for
|
|
505
|
+
expressions over the row, and the assignment-side accessors, for the left of a
|
|
506
|
+
`SET`. The second yields `Col`, which carries the column's name, so `set` reads
|
|
507
|
+
it rather than recovering it from rendered SQL — an aggregate or a `COALESCE`
|
|
508
|
+
cannot be assigned to, and cannot be offered.
|
|
509
|
+
|
|
297
510
|
Use these in `update_all` to avoid rewriting an array column wholesale:
|
|
298
511
|
|
|
299
512
|
```jade
|
|
300
513
|
appointments
|
|
301
514
|
|> update_all(
|
|
302
|
-
(a) -> { a.id |> eq(
|
|
303
|
-
(a) -> { [
|
|
515
|
+
(a) -> { a.id |> eq(aid) },
|
|
516
|
+
(a, s) -> { [s.tags |> set_expr(array_append(a.tags, new_tag))] },
|
|
304
517
|
)
|
|
305
518
|
# UPDATE appointments SET tags = array_append(tags, ?) WHERE id = ?
|
|
306
519
|
```
|
|
@@ -312,70 +525,93 @@ Out of scope: `unnest`, `array_agg`. Add when a caller hits them.
|
|
|
312
525
|
| Function | SQL |
|
|
313
526
|
|-----------------------------------------------------------|--------------------|
|
|
314
527
|
| `jsonb_contains(Expr(Value), a) -> Expr(Bool)` | `col @> ?` |
|
|
315
|
-
| `jsonb_path_exists(Expr(Value), String) -> Expr(Bool)` | `col
|
|
528
|
+
| `jsonb_path_exists(Expr(Value), String) -> Expr(Bool)` | `jsonb_path_exists(col, ?)` |
|
|
316
529
|
|
|
317
530
|
`jsonb_contains` auto-encodes the value via its `Encodable` instance,
|
|
318
531
|
so you can pass any record / scalar / list directly:
|
|
319
532
|
|
|
320
533
|
```jade
|
|
321
|
-
import Sql exposing (
|
|
534
|
+
import Sql exposing (jsonb_contains, jsonb_path_exists)
|
|
322
535
|
|
|
323
536
|
struct KindMatch = { kind: String }
|
|
324
537
|
|
|
325
538
|
# WHERE r.meta @> ? (param: { "kind": "referral" })
|
|
326
|
-
def matches_kind(k: String) -> Expr(Bool)
|
|
327
|
-
jsonb_contains(
|
|
539
|
+
def matches_kind(k: String) -> (ReferralsCols -> Expr(Bool))
|
|
540
|
+
(r) -> { jsonb_contains(r.meta, KindMatch(k)) }
|
|
328
541
|
end
|
|
329
542
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
543
|
+
|
|
544
|
+
# WHERE jsonb_path_exists(r.meta, ?) (param: "$.priority ? (@ > 1)")
|
|
545
|
+
def has_priority_gt(path: String) -> (ReferralsCols -> Expr(Bool))
|
|
546
|
+
(r) -> { jsonb_path_exists(r.meta, path) }
|
|
333
547
|
end
|
|
334
548
|
```
|
|
335
549
|
|
|
336
|
-
|
|
337
|
-
|
|
550
|
+
Postgres spells four of its jsonb operators with a `?` — `@?`, `?`, `?|` and
|
|
551
|
+
`?&` — and the runtime rewrites every `?` outside a quoted span into a `$n`
|
|
552
|
+
placeholder. It cannot tell an operator from a parameter, since `meta ? 'kind'`
|
|
553
|
+
and `id = ?` are the same character in the same position. So `jsonb_path_exists`
|
|
554
|
+
renders the function Postgres gives for `@?` rather than the operator: it holds
|
|
555
|
+
no `?`, and its declared parameter type supplies the `jsonpath` the operator
|
|
556
|
+
form needed a cast for. Anything added here later wants the same treatment.
|
|
338
557
|
|
|
339
|
-
## Build
|
|
558
|
+
## Build writes
|
|
340
559
|
|
|
341
|
-
|
|
560
|
+
One interface says which columns a value writes:
|
|
342
561
|
|
|
343
562
|
```jade
|
|
344
|
-
import Sql exposing(Assignment,
|
|
345
|
-
import Encode exposing(encode)
|
|
346
|
-
import Decode exposing(Value)
|
|
563
|
+
import Sql exposing(Assignment, Assignable, assign)
|
|
347
564
|
|
|
348
565
|
struct Patient = { id: Int, name: String, mrn: String }
|
|
349
566
|
|
|
350
|
-
implements
|
|
567
|
+
implements Assignable(Patient) with
|
|
351
568
|
to_assigns: encode_patient
|
|
352
569
|
end
|
|
353
570
|
|
|
354
|
-
implements Identified(Patient) with
|
|
355
|
-
pk_values: encode_patient_pk
|
|
356
|
-
end
|
|
357
|
-
|
|
358
571
|
def encode_patient(p: Patient) -> List(Assignment)
|
|
359
572
|
[
|
|
573
|
+
assign("id", p.id),
|
|
360
574
|
assign("name", p.name),
|
|
361
575
|
assign("mrn", p.mrn)
|
|
362
576
|
]
|
|
363
577
|
end
|
|
578
|
+
```
|
|
364
579
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
580
|
+
`Assignable` derives for any struct, so the `implements` block above is
|
|
581
|
+
only needed when you want something other than one column per field.
|
|
582
|
+
|
|
583
|
+
**The struct you pass is the columns you write.** `insert` writes every
|
|
584
|
+
assignment. `update` and `delete` take the key as an argument, so what you
|
|
585
|
+
write and which row you write it to stay separate:
|
|
586
|
+
|
|
587
|
+
```jade
|
|
588
|
+
struct NewPatient = { name: String, mrn: String }
|
|
589
|
+
|
|
590
|
+
struct Rename = { name: String }
|
|
591
|
+
|
|
592
|
+
NewPatient("Ada", "MRN-1") |> insert(patients) # INSERT (name, mrn)
|
|
593
|
+
Rename("Ada") |> update(patients, 7) # SET name WHERE id = 7
|
|
594
|
+
delete(patients, 7) # DELETE WHERE id = 7
|
|
368
595
|
```
|
|
369
596
|
|
|
597
|
+
A patch needs no key field of its own, and a struct that does carry one —
|
|
598
|
+
a whole row — has it stripped from the `SET`. Every write is either keyed
|
|
599
|
+
or scoped: `update` and `delete` take a key, `update_all` and `delete_all`
|
|
600
|
+
take a predicate, and there is no third form to land in with neither.
|
|
601
|
+
|
|
602
|
+
The key is a `k`, never a column name and never an order, so a composite
|
|
603
|
+
key cannot be listed the wrong way round — the generated values function
|
|
604
|
+
spreads it across its columns as `structure.sql` declares them.
|
|
605
|
+
|
|
370
606
|
`assign(col, value)` is shorthand for
|
|
371
607
|
`Assignment(col, "?", [encode(value)])`. For non-`?` placeholders
|
|
372
608
|
(e.g. `"visit_no + ?"` for increments) use the `Assignment(...)`
|
|
373
609
|
constructor directly.
|
|
374
610
|
|
|
375
|
-
Then the
|
|
611
|
+
Then the write API works on values directly:
|
|
376
612
|
|
|
377
613
|
```jade
|
|
378
|
-
import Sql.
|
|
614
|
+
import Sql.Write exposing(insert, update, delete, insert_all, update_all, delete_all, to_sql)
|
|
379
615
|
|
|
380
616
|
p |> insert(patients) |> to_sql # INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
381
617
|
p |> update(patients) |> to_sql # UPDATE patients SET name = ?, mrn = ? WHERE id = ?
|
|
@@ -384,30 +620,82 @@ p |> delete(patients) |> to_sql # DELETE FROM patients WHERE id = ?
|
|
|
384
620
|
[p1, p2] |> insert_all(patients) |> to_sql
|
|
385
621
|
|
|
386
622
|
appointments
|
|
387
|
-
|> update_all((a) -> { a.status |> eq(
|
|
388
|
-
(a) -> { [
|
|
623
|
+
|> update_all((a) -> { a.status |> eq("scheduled") },
|
|
624
|
+
(a, s) -> { [s.cancelled |> set(True)] })
|
|
389
625
|
|> to_sql
|
|
390
626
|
|
|
391
627
|
appointments
|
|
392
|
-
|> delete_all((a) -> { a.cancelled |> eq(
|
|
628
|
+
|> delete_all((a) -> { a.cancelled |> eq(True) })
|
|
393
629
|
|> to_sql
|
|
394
630
|
```
|
|
395
631
|
|
|
632
|
+
### Upserts
|
|
633
|
+
|
|
634
|
+
`on_conflict` takes a `Unique` as the conflict target, so the index named is
|
|
635
|
+
one the database has, and an action saying which of the two forms it is. The
|
|
636
|
+
write already knows its table, so `do_update` receives the `SET` columns
|
|
637
|
+
without being handed the table again:
|
|
638
|
+
|
|
639
|
+
```jade
|
|
640
|
+
import Sql exposing (set_excluded)
|
|
641
|
+
import Sql.Write exposing (do_nothing, do_update, insert, on_conflict)
|
|
642
|
+
|
|
643
|
+
NewUser("ada@example.com", "ada")
|
|
644
|
+
|> insert(users)
|
|
645
|
+
|> on_conflict(users_email_key, do_nothing)
|
|
646
|
+
# INSERT INTO users AS users (email, handle) VALUES (?, ?) ON CONFLICT (email) DO NOTHING
|
|
647
|
+
|
|
648
|
+
NewUser("ada@example.com", "ada")
|
|
649
|
+
|> insert(users)
|
|
650
|
+
|> on_conflict(users_email_key, do_update((s) -> { [set_excluded(s.handle)] }))
|
|
651
|
+
# ... ON CONFLICT (email) DO UPDATE SET handle = EXCLUDED.handle
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
A primary key is a unique index like any other, so it is generated as one
|
|
655
|
+
under the name the DDL gives it — which is what `upsert_all` targets:
|
|
656
|
+
|
|
657
|
+
```jade
|
|
658
|
+
row |> insert(users) |> on_conflict(users_pkey, do_update((s) -> { ... }))
|
|
659
|
+
# ... ON CONFLICT (id) DO UPDATE SET ...
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
`val(v)` puts a value where an expression is wanted. The operators take
|
|
663
|
+
values directly, so this is for the positions that cannot — a constant field
|
|
664
|
+
in a projection or a JSON document:
|
|
665
|
+
|
|
666
|
+
```jade
|
|
667
|
+
select(Row(_, _)) |> field(c.id) |> field_as(val("patient"), "kind")
|
|
668
|
+
# SELECT patients.id, ? AS kind FROM patients patients
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
It binds rather than inlining, so a string with a quote in it is a parameter
|
|
672
|
+
and not a syntax error.
|
|
673
|
+
|
|
674
|
+
`set_excluded(col)` renders `col = EXCLUDED.col`, which is what an upsert wants
|
|
675
|
+
nearly every time. For anything else, `excluded(col)` is the proposed row's
|
|
676
|
+
value as an ordinary `Expr`, so `set_expr(s.count_, excluded(s.count_))` and
|
|
677
|
+
arithmetic over it compose as usual.
|
|
678
|
+
|
|
679
|
+
The build receives the table's `SET` columns, which is why the table is passed
|
|
680
|
+
again: a `Write` carries its columns aliased for the statement, and the left of
|
|
681
|
+
a `SET` takes no alias.
|
|
682
|
+
|
|
396
683
|
### RETURNING
|
|
397
684
|
|
|
398
|
-
`returning` is the
|
|
685
|
+
`returning` is the write-side counterpart to `select` for queries.
|
|
399
686
|
It takes a closure that receives the table's column accessors and
|
|
400
|
-
builds a
|
|
401
|
-
|
|
687
|
+
builds a Query-wrapped selector projecting them into a target type. The
|
|
688
|
+
Query wrapper is just to share the same `select`/`field` builders as
|
|
402
689
|
queries — `returning` extracts the inner `Selector` and discards the
|
|
403
|
-
empty
|
|
690
|
+
empty Query state.
|
|
404
691
|
|
|
405
692
|
```jade
|
|
406
693
|
import Sql exposing(Selector)
|
|
407
694
|
import Sql.Query exposing(select, field)
|
|
408
|
-
import Sql.
|
|
695
|
+
import Sql.Write exposing(insert, returning, to_sql)
|
|
409
696
|
|
|
410
|
-
# INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
697
|
+
# INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
698
|
+
# RETURNING patients.id, patients.name, patients.mrn
|
|
411
699
|
np
|
|
412
700
|
|> insert(patients)
|
|
413
701
|
|> returning((p) -> {
|
|
@@ -421,11 +709,11 @@ np
|
|
|
421
709
|
|
|
422
710
|
Bonus: the projector can be defined once and shared between SELECT
|
|
423
711
|
queries and RETURNING — both contexts now take the same `cols ->
|
|
424
|
-
|
|
712
|
+
Select(target)` shape, so a single `def patient_projector(p)`
|
|
425
713
|
works for `from(patients) |> patient_projector` (query) and
|
|
426
714
|
`... |> returning(patient_projector)` (RETURNING).
|
|
427
715
|
|
|
428
|
-
Combined with `Sql.fetch_one`, the inserted row decodes into the
|
|
716
|
+
Combined with `Sql.Write.fetch_one`, the inserted row decodes into the
|
|
429
717
|
target struct:
|
|
430
718
|
|
|
431
719
|
```jade
|
|
@@ -440,11 +728,17 @@ def create(np: NewPatient) -> Task(Patient, SqlError)
|
|
|
440
728
|
end
|
|
441
729
|
```
|
|
442
730
|
|
|
443
|
-
`
|
|
444
|
-
`
|
|
445
|
-
|
|
731
|
+
`filter` narrows a query or a write you have already built. Where
|
|
732
|
+
`where` takes a predicate, `filter` takes a *function* of the columns, so a
|
|
733
|
+
caller that has not bound them can still add one — enough to write a
|
|
734
|
+
tenancy wrapper that scopes a keyed write, rather than funnelling every
|
|
735
|
+
scoped write through the `_all` forms.
|
|
446
736
|
|
|
447
|
-
`
|
|
737
|
+
`insert` / `insert_all` / `update` / `delete` need `Assignable(a)`, and
|
|
738
|
+
nothing else. `update_all`/`delete_all` build the SET / WHERE clauses
|
|
739
|
+
directly from the column accessors — no codec.
|
|
740
|
+
|
|
741
|
+
`Assignable` is also implemented for `List(Assignment)` itself, so you
|
|
448
742
|
can pass an assignment list to `insert` directly when you've already
|
|
449
743
|
built it (e.g. from a sparse changeset):
|
|
450
744
|
|
|
@@ -457,18 +751,26 @@ sparse_changes
|
|
|
457
751
|
### Timestamps
|
|
458
752
|
|
|
459
753
|
`insert`/`update` emit only the columns you set — they don't auto-fill
|
|
460
|
-
`created_at` / `updated_at`. Opt in per-write with `timestamped`, which
|
|
461
|
-
works like ActiveRecord: `created_at` +
|
|
462
|
-
only on update.
|
|
754
|
+
`created_at` / `updated_at`. Opt in per-write with `timestamped`, which wraps
|
|
755
|
+
the **value** being written and works like ActiveRecord: `created_at` +
|
|
756
|
+
`updated_at` on insert, `updated_at` only on update.
|
|
463
757
|
|
|
464
758
|
```jade
|
|
465
|
-
import Sql.
|
|
759
|
+
import Sql.Write exposing (insert, timestamped, update)
|
|
466
760
|
|
|
467
|
-
new_patient |>
|
|
468
|
-
|
|
469
|
-
new_import
|
|
761
|
+
insert(new_patient |> timestamped, patients) |> execute -- both set
|
|
762
|
+
update(patch |> timestamped, patients, id) |> execute -- updated_at only
|
|
763
|
+
insert(new_import, patients) |> execute -- no timestamps
|
|
470
764
|
```
|
|
471
765
|
|
|
766
|
+
It wraps the value rather than the built write so the required-columns check
|
|
767
|
+
sees it: a table declaring the timestamps NOT NULL demands them of the value,
|
|
768
|
+
and a pipe further down the chain could not answer for that.
|
|
769
|
+
|
|
770
|
+
`update` drops the `created_at` the wrapper added, not any `created_at` you
|
|
771
|
+
assigned yourself — the wrapper writes a clock token the runtime substitutes,
|
|
772
|
+
so the two are distinguishable and a backdating update still lands.
|
|
773
|
+
|
|
472
774
|
It's opt-in on purpose — backfills, imports, and `touch: false`-style writes
|
|
473
775
|
just omit it (and can set the columns explicitly). The value is the **app
|
|
474
776
|
clock** at execute time (set in Ruby, the same clock Rails uses, so
|