jade-sql 0.7.0 → 0.9.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 +411 -113
- data/docs/running.md +103 -18
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +702 -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.rb +32 -0
- data/lib/jade-sql/runtime.rb +74 -23
- 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 +309 -93
- data/lib/jade-sql/sql/write.jd +618 -0
- data/lib/jade-sql/sql.jd +389 -103
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +92 -14
- metadata +15 -8
- data/lib/jade-sql/sql/mutation.jd +0 -464
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)` asks whether a related row is there, without joining to it and
|
|
285
|
+
without projecting anything from it; `not(exists(q))` is the negative. 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,78 @@ 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` asks whether a related row is there, `subquery` reads a single value
|
|
408
|
+
out of one, and `in_subquery` matches a column against one a subquery selects.
|
|
409
|
+
All three take an unprojected `Query`.
|
|
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(q))` is the
|
|
413
|
+
form to reach for.
|
|
414
|
+
|
|
415
|
+
```jade
|
|
416
|
+
import Sql.Query exposing (
|
|
417
|
+
from,
|
|
418
|
+
in_subquery,
|
|
419
|
+
limit,
|
|
420
|
+
order_desc,
|
|
421
|
+
subquery,
|
|
422
|
+
where,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def latest(p: PatientsCols) -> Query(VisitsCols)
|
|
426
|
+
v <- from(visits)
|
|
427
|
+
|
|
428
|
+
from(visits)
|
|
429
|
+
|> where(v.patient_id |> Expr.eq(p.id))
|
|
430
|
+
|> order_desc(v.seen_on)
|
|
431
|
+
|> limit(1)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
select(Row(_, _))
|
|
435
|
+
|> field(p.id)
|
|
436
|
+
|> field(subquery(latest(p), .seen_on))
|
|
437
|
+
# SELECT p.id, (SELECT v.seen_on FROM visits v WHERE v.patient_id = p.id
|
|
438
|
+
# ORDER BY v.seen_on DESC LIMIT 1) FROM patients p
|
|
439
|
+
|
|
440
|
+
where(p.id |> in_subquery(from(visits), .patient_id))
|
|
441
|
+
# WHERE p.id IN (SELECT v.patient_id FROM visits v)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
A subquery starts with `from(t)`, the same as any other query, and carries the
|
|
445
|
+
table's columns rather than a projection. Naming the table again is what makes
|
|
446
|
+
the subquery stand on its own: one that borrowed the outer query's columns
|
|
447
|
+
would render without a `FROM` and read the outer table instead, which is legal
|
|
448
|
+
SQL asking a different question. Naming a table the chain already bound costs
|
|
449
|
+
nothing, since a table is listed once however many times it is named. The
|
|
450
|
+
column is picked by a function rather than projected, because
|
|
451
|
+
`Select(a)` does not say how many columns it has, and a subquery in a value
|
|
452
|
+
position may only have one. `subquery` returns `Expr(Maybe(a))`, since a
|
|
453
|
+
subquery matching no rows is NULL.
|
|
454
|
+
|
|
257
455
|
### Postgres arrays
|
|
258
456
|
|
|
259
457
|
Typed predicates on `text[]` / `int[]` / `uuid[]` columns. All bind
|
|
@@ -272,21 +470,28 @@ the other side of the operator. No `ARRAY[$1,...,$N]` expansion, no
|
|
|
272
470
|
|
|
273
471
|
Example — filter appointments whose tag set overlaps any selected chip:
|
|
274
472
|
|
|
473
|
+
A predicate written outside a bind chain has no accessors in scope, so take
|
|
474
|
+
them as an argument and hand it to `filter`, which supplies them:
|
|
475
|
+
|
|
275
476
|
```jade
|
|
276
|
-
import Sql exposing (array_overlaps
|
|
477
|
+
import Sql exposing (array_overlaps)
|
|
478
|
+
import Sql.Query exposing (filter, from)
|
|
277
479
|
|
|
278
|
-
def
|
|
279
|
-
array_overlaps(
|
|
480
|
+
def tagged(selected: List(String)) -> (AppointmentsCols -> Expr(Bool))
|
|
481
|
+
(a) -> { array_overlaps(a.tags, selected) }
|
|
280
482
|
end
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
from(appointments) |> filter(tagged(["urgent", "followup"]))
|
|
281
486
|
# WHERE a.tags && ? (param: ["urgent","followup"])
|
|
282
487
|
```
|
|
283
488
|
|
|
284
489
|
`array_length` uses `cardinality(col)` rather than Postgres'
|
|
285
490
|
`array_length(col, 1)` because `cardinality` is non-null (returns 0
|
|
286
491
|
on empty). Filter untagged rows with
|
|
287
|
-
`array_length(
|
|
492
|
+
`array_length(a.tags) |> eq(0)`.
|
|
288
493
|
|
|
289
|
-
|
|
494
|
+
Write ops for partial array updates:
|
|
290
495
|
|
|
291
496
|
| Function | SQL |
|
|
292
497
|
|---------------------------------------------------------------------|------------------------------|
|
|
@@ -294,13 +499,19 @@ Mutation ops for partial array updates:
|
|
|
294
499
|
| `array_remove(Expr(List(a)), a) -> Expr(List(a))` | `array_remove(col, ?)` |
|
|
295
500
|
| `array_concat(Expr(List(a)), Expr(List(a))) -> Expr(List(a))` | `left ‖ right` |
|
|
296
501
|
|
|
502
|
+
`update_all`'s builder receives two records: the column accessors, for
|
|
503
|
+
expressions over the row, and the assignment-side accessors, for the left of a
|
|
504
|
+
`SET`. The second yields `Col`, which carries the column's name, so `set` reads
|
|
505
|
+
it rather than recovering it from rendered SQL — an aggregate or a `COALESCE`
|
|
506
|
+
cannot be assigned to, and cannot be offered.
|
|
507
|
+
|
|
297
508
|
Use these in `update_all` to avoid rewriting an array column wholesale:
|
|
298
509
|
|
|
299
510
|
```jade
|
|
300
511
|
appointments
|
|
301
512
|
|> update_all(
|
|
302
|
-
(a) -> { a.id |> eq(
|
|
303
|
-
(a) -> { [
|
|
513
|
+
(a) -> { a.id |> eq(aid) },
|
|
514
|
+
(a, s) -> { [s.tags |> set_expr(array_append(a.tags, new_tag))] },
|
|
304
515
|
)
|
|
305
516
|
# UPDATE appointments SET tags = array_append(tags, ?) WHERE id = ?
|
|
306
517
|
```
|
|
@@ -312,70 +523,93 @@ Out of scope: `unnest`, `array_agg`. Add when a caller hits them.
|
|
|
312
523
|
| Function | SQL |
|
|
313
524
|
|-----------------------------------------------------------|--------------------|
|
|
314
525
|
| `jsonb_contains(Expr(Value), a) -> Expr(Bool)` | `col @> ?` |
|
|
315
|
-
| `jsonb_path_exists(Expr(Value), String) -> Expr(Bool)` | `col
|
|
526
|
+
| `jsonb_path_exists(Expr(Value), String) -> Expr(Bool)` | `jsonb_path_exists(col, ?)` |
|
|
316
527
|
|
|
317
528
|
`jsonb_contains` auto-encodes the value via its `Encodable` instance,
|
|
318
529
|
so you can pass any record / scalar / list directly:
|
|
319
530
|
|
|
320
531
|
```jade
|
|
321
|
-
import Sql exposing (
|
|
532
|
+
import Sql exposing (jsonb_contains, jsonb_path_exists)
|
|
322
533
|
|
|
323
534
|
struct KindMatch = { kind: String }
|
|
324
535
|
|
|
325
536
|
# WHERE r.meta @> ? (param: { "kind": "referral" })
|
|
326
|
-
def matches_kind(k: String) -> Expr(Bool)
|
|
327
|
-
jsonb_contains(
|
|
537
|
+
def matches_kind(k: String) -> (ReferralsCols -> Expr(Bool))
|
|
538
|
+
(r) -> { jsonb_contains(r.meta, KindMatch(k)) }
|
|
328
539
|
end
|
|
329
540
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
541
|
+
|
|
542
|
+
# WHERE jsonb_path_exists(r.meta, ?) (param: "$.priority ? (@ > 1)")
|
|
543
|
+
def has_priority_gt(path: String) -> (ReferralsCols -> Expr(Bool))
|
|
544
|
+
(r) -> { jsonb_path_exists(r.meta, path) }
|
|
333
545
|
end
|
|
334
546
|
```
|
|
335
547
|
|
|
336
|
-
|
|
337
|
-
|
|
548
|
+
Postgres spells four of its jsonb operators with a `?` — `@?`, `?`, `?|` and
|
|
549
|
+
`?&` — and the runtime rewrites every `?` outside a quoted span into a `$n`
|
|
550
|
+
placeholder. It cannot tell an operator from a parameter, since `meta ? 'kind'`
|
|
551
|
+
and `id = ?` are the same character in the same position. So `jsonb_path_exists`
|
|
552
|
+
renders the function Postgres gives for `@?` rather than the operator: it holds
|
|
553
|
+
no `?`, and its declared parameter type supplies the `jsonpath` the operator
|
|
554
|
+
form needed a cast for. Anything added here later wants the same treatment.
|
|
338
555
|
|
|
339
|
-
## Build
|
|
556
|
+
## Build writes
|
|
340
557
|
|
|
341
|
-
|
|
558
|
+
One interface says which columns a value writes:
|
|
342
559
|
|
|
343
560
|
```jade
|
|
344
|
-
import Sql exposing(Assignment,
|
|
345
|
-
import Encode exposing(encode)
|
|
346
|
-
import Decode exposing(Value)
|
|
561
|
+
import Sql exposing(Assignment, Assignable, assign)
|
|
347
562
|
|
|
348
563
|
struct Patient = { id: Int, name: String, mrn: String }
|
|
349
564
|
|
|
350
|
-
implements
|
|
565
|
+
implements Assignable(Patient) with
|
|
351
566
|
to_assigns: encode_patient
|
|
352
567
|
end
|
|
353
568
|
|
|
354
|
-
implements Identified(Patient) with
|
|
355
|
-
pk_values: encode_patient_pk
|
|
356
|
-
end
|
|
357
|
-
|
|
358
569
|
def encode_patient(p: Patient) -> List(Assignment)
|
|
359
570
|
[
|
|
571
|
+
assign("id", p.id),
|
|
360
572
|
assign("name", p.name),
|
|
361
573
|
assign("mrn", p.mrn)
|
|
362
574
|
]
|
|
363
575
|
end
|
|
576
|
+
```
|
|
364
577
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
578
|
+
`Assignable` derives for any struct, so the `implements` block above is
|
|
579
|
+
only needed when you want something other than one column per field.
|
|
580
|
+
|
|
581
|
+
**The struct you pass is the columns you write.** `insert` writes every
|
|
582
|
+
assignment. `update` and `delete` take the key as an argument, so what you
|
|
583
|
+
write and which row you write it to stay separate:
|
|
584
|
+
|
|
585
|
+
```jade
|
|
586
|
+
struct NewPatient = { name: String, mrn: String }
|
|
587
|
+
|
|
588
|
+
struct Rename = { name: String }
|
|
589
|
+
|
|
590
|
+
NewPatient("Ada", "MRN-1") |> insert(patients) # INSERT (name, mrn)
|
|
591
|
+
Rename("Ada") |> update(patients, 7) # SET name WHERE id = 7
|
|
592
|
+
delete(patients, 7) # DELETE WHERE id = 7
|
|
368
593
|
```
|
|
369
594
|
|
|
595
|
+
A patch needs no key field of its own, and a struct that does carry one —
|
|
596
|
+
a whole row — has it stripped from the `SET`. Every write is either keyed
|
|
597
|
+
or scoped: `update` and `delete` take a key, `update_all` and `delete_all`
|
|
598
|
+
take a predicate, and there is no third form to land in with neither.
|
|
599
|
+
|
|
600
|
+
The key is a `k`, never a column name and never an order, so a composite
|
|
601
|
+
key cannot be listed the wrong way round — the generated values function
|
|
602
|
+
spreads it across its columns as `structure.sql` declares them.
|
|
603
|
+
|
|
370
604
|
`assign(col, value)` is shorthand for
|
|
371
605
|
`Assignment(col, "?", [encode(value)])`. For non-`?` placeholders
|
|
372
606
|
(e.g. `"visit_no + ?"` for increments) use the `Assignment(...)`
|
|
373
607
|
constructor directly.
|
|
374
608
|
|
|
375
|
-
Then the
|
|
609
|
+
Then the write API works on values directly:
|
|
376
610
|
|
|
377
611
|
```jade
|
|
378
|
-
import Sql.
|
|
612
|
+
import Sql.Write exposing(insert, update, delete, insert_all, update_all, delete_all, to_sql)
|
|
379
613
|
|
|
380
614
|
p |> insert(patients) |> to_sql # INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
381
615
|
p |> update(patients) |> to_sql # UPDATE patients SET name = ?, mrn = ? WHERE id = ?
|
|
@@ -384,33 +618,84 @@ p |> delete(patients) |> to_sql # DELETE FROM patients WHERE id = ?
|
|
|
384
618
|
[p1, p2] |> insert_all(patients) |> to_sql
|
|
385
619
|
|
|
386
620
|
appointments
|
|
387
|
-
|> update_all((a) -> { a.status |> eq(
|
|
388
|
-
(a) -> { [
|
|
621
|
+
|> update_all((a) -> { a.status |> eq("scheduled") },
|
|
622
|
+
(a, s) -> { [s.cancelled |> set(True)] })
|
|
389
623
|
|> to_sql
|
|
390
624
|
|
|
391
625
|
appointments
|
|
392
|
-
|> delete_all((a) -> { a.cancelled |> eq(
|
|
626
|
+
|> delete_all((a) -> { a.cancelled |> eq(True) })
|
|
393
627
|
|> to_sql
|
|
394
628
|
```
|
|
395
629
|
|
|
630
|
+
### Upserts
|
|
631
|
+
|
|
632
|
+
`on_conflict` takes a `Unique` as the conflict target, so the index named is
|
|
633
|
+
one the database has, and an action saying which of the two forms it is. The
|
|
634
|
+
write already knows its table, so `do_update` receives the `SET` columns
|
|
635
|
+
without being handed the table again:
|
|
636
|
+
|
|
637
|
+
```jade
|
|
638
|
+
import Sql exposing (set_excluded)
|
|
639
|
+
import Sql.Write exposing (do_nothing, do_update, insert, on_conflict)
|
|
640
|
+
|
|
641
|
+
NewUser("ada@example.com", "ada")
|
|
642
|
+
|> insert(users)
|
|
643
|
+
|> on_conflict(users_email_key, do_nothing)
|
|
644
|
+
# INSERT INTO users AS users (email, handle) VALUES (?, ?) ON CONFLICT (email) DO NOTHING
|
|
645
|
+
|
|
646
|
+
NewUser("ada@example.com", "ada")
|
|
647
|
+
|> insert(users)
|
|
648
|
+
|> on_conflict(users_email_key, do_update((s) -> { [set_excluded(s.handle)] }))
|
|
649
|
+
# ... ON CONFLICT (email) DO UPDATE SET handle = EXCLUDED.handle
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
A primary key is a unique index like any other, so it is generated as one
|
|
653
|
+
under the name the DDL gives it — which is what `upsert_all` targets:
|
|
654
|
+
|
|
655
|
+
```jade
|
|
656
|
+
row |> insert(users) |> on_conflict(users_pkey, do_update((s) -> { ... }))
|
|
657
|
+
# ... ON CONFLICT (id) DO UPDATE SET ...
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`val(v)` puts a value where an expression is wanted. The operators take
|
|
661
|
+
values directly, so this is for the positions that cannot — a constant field
|
|
662
|
+
in a projection or a JSON document:
|
|
663
|
+
|
|
664
|
+
```jade
|
|
665
|
+
select(Row(_, _)) |> field(c.id) |> field_as(val("patient"), "kind")
|
|
666
|
+
# SELECT patients.id, ? AS kind FROM patients patients
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
It binds rather than inlining, so a string with a quote in it is a parameter
|
|
670
|
+
and not a syntax error.
|
|
671
|
+
|
|
672
|
+
`set_excluded(col)` renders `col = EXCLUDED.col`, which is what an upsert wants
|
|
673
|
+
nearly every time. For anything else, `excluded(col)` is the proposed row's
|
|
674
|
+
value as an ordinary `Expr`, so `set_expr(s.count_, excluded(s.count_))` and
|
|
675
|
+
arithmetic over it compose as usual.
|
|
676
|
+
|
|
677
|
+
The build receives the table's `SET` columns, which is why the table is passed
|
|
678
|
+
again: a `Write` carries its columns aliased for the statement, and the left of
|
|
679
|
+
a `SET` takes no alias.
|
|
680
|
+
|
|
396
681
|
### RETURNING
|
|
397
682
|
|
|
398
|
-
`
|
|
399
|
-
It
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
empty Q state.
|
|
683
|
+
`returning_with` names the columns coming back, from the table's accessors.
|
|
684
|
+
It is a step of its own rather than something `fetch_one` does for you: a
|
|
685
|
+
write has one type whether you run it for a count or for a row, so folding the
|
|
686
|
+
projection into the runner would mean `execute` and `fetch_one` producing
|
|
687
|
+
different SQL from the same `Write`.
|
|
404
688
|
|
|
405
689
|
```jade
|
|
406
690
|
import Sql exposing(Selector)
|
|
407
691
|
import Sql.Query exposing(select, field)
|
|
408
|
-
import Sql.
|
|
692
|
+
import Sql.Write exposing(insert, returning_with, to_sql)
|
|
409
693
|
|
|
410
|
-
# INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
694
|
+
# INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
695
|
+
# RETURNING patients.id, patients.name, patients.mrn
|
|
411
696
|
np
|
|
412
697
|
|> insert(patients)
|
|
413
|
-
|>
|
|
698
|
+
|> returning_with((p) -> {
|
|
414
699
|
select(Patient(_, _, _))
|
|
415
700
|
|> field(p.id)
|
|
416
701
|
|> field(p.name)
|
|
@@ -419,32 +704,37 @@ np
|
|
|
419
704
|
|> to_sql # or |> fetch_one to run
|
|
420
705
|
```
|
|
421
706
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
works for `from(patients) |> patient_projector` (query) and
|
|
426
|
-
`... |> returning(patient_projector)` (RETURNING).
|
|
427
|
-
|
|
428
|
-
Combined with `Sql.fetch_one`, the inserted row decodes into the
|
|
429
|
-
target struct:
|
|
707
|
+
A projection is a function of the columns, so it is written once and shared
|
|
708
|
+
between a SELECT and a RETURNING — both take the same `cols -> Select(a)`
|
|
709
|
+
shape:
|
|
430
710
|
|
|
431
711
|
```jade
|
|
432
|
-
def
|
|
433
|
-
|
|
434
|
-
select(Patient(_, _, _))
|
|
435
|
-
|> field(p.id)
|
|
436
|
-
|> field(p.name)
|
|
437
|
-
|> field(p.mrn)
|
|
438
|
-
})
|
|
439
|
-
|> fetch_one
|
|
712
|
+
def patient_row(p: PatientsCols) -> Select(Patient)
|
|
713
|
+
select(Patient(_, _, _)) |> field(p.id) |> field(p.name) |> field(p.mrn)
|
|
440
714
|
end
|
|
441
715
|
```
|
|
442
716
|
|
|
443
|
-
`
|
|
444
|
-
|
|
445
|
-
|
|
717
|
+
`from(patients) |> patient_row` for the read, `returning_with(patient_row)`
|
|
718
|
+
for the write. With row polymorphism a projection can span tables — one
|
|
719
|
+
`def just_id(c: { a | id: Expr(Int) })` serves every table with an `id`.
|
|
446
720
|
|
|
447
|
-
|
|
721
|
+
That is why there is no version that reads the columns off the result type's
|
|
722
|
+
field names. It would save the `field` lines, and it is the one thing in the
|
|
723
|
+
library that could name a column the table does not have — a projection built
|
|
724
|
+
from accessors cannot.
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
`filter` narrows a query or a write you have already built. Where
|
|
728
|
+
`where` takes a predicate, `filter` takes a *function* of the columns, so a
|
|
729
|
+
caller that has not bound them can still add one — enough to write a
|
|
730
|
+
tenancy wrapper that scopes a keyed write, rather than funnelling every
|
|
731
|
+
scoped write through the `_all` forms.
|
|
732
|
+
|
|
733
|
+
`insert` / `insert_all` / `update` / `delete` need `Assignable(a)`, and
|
|
734
|
+
nothing else. `update_all`/`delete_all` build the SET / WHERE clauses
|
|
735
|
+
directly from the column accessors — no codec.
|
|
736
|
+
|
|
737
|
+
`Assignable` is also implemented for `List(Assignment)` itself, so you
|
|
448
738
|
can pass an assignment list to `insert` directly when you've already
|
|
449
739
|
built it (e.g. from a sparse changeset):
|
|
450
740
|
|
|
@@ -457,18 +747,26 @@ sparse_changes
|
|
|
457
747
|
### Timestamps
|
|
458
748
|
|
|
459
749
|
`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.
|
|
750
|
+
`created_at` / `updated_at`. Opt in per-write with `timestamped`, which wraps
|
|
751
|
+
the **value** being written and works like ActiveRecord: `created_at` +
|
|
752
|
+
`updated_at` on insert, `updated_at` only on update.
|
|
463
753
|
|
|
464
754
|
```jade
|
|
465
|
-
import Sql.
|
|
755
|
+
import Sql.Write exposing (insert, timestamped, update)
|
|
466
756
|
|
|
467
|
-
new_patient |>
|
|
468
|
-
|
|
469
|
-
new_import
|
|
757
|
+
insert(new_patient |> timestamped, patients) |> execute -- both set
|
|
758
|
+
update(patch |> timestamped, patients, id) |> execute -- updated_at only
|
|
759
|
+
insert(new_import, patients) |> execute -- no timestamps
|
|
470
760
|
```
|
|
471
761
|
|
|
762
|
+
It wraps the value rather than the built write so the required-columns check
|
|
763
|
+
sees it: a table declaring the timestamps NOT NULL demands them of the value,
|
|
764
|
+
and a pipe further down the chain could not answer for that.
|
|
765
|
+
|
|
766
|
+
`update` drops the `created_at` the wrapper added, not any `created_at` you
|
|
767
|
+
assigned yourself — the wrapper writes a clock token the runtime substitutes,
|
|
768
|
+
so the two are distinguishable and a backdating update still lands.
|
|
769
|
+
|
|
472
770
|
It's opt-in on purpose — backfills, imports, and `touch: false`-style writes
|
|
473
771
|
just omit it (and can set the columns explicitly). The value is the **app
|
|
474
772
|
clock** at execute time (set in Ruby, the same clock Rails uses, so
|