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.
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
- mutations against it. To run what you build, see [running.md](running.md).
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`). Unknown types fail
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 MaybePatientsCols = { id: Expr(Maybe(Int)), name: Expr(Maybe(String)), ... }
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, MaybePatientsCols)
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 -> Q(Selector(JournalEntriesRow))
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, to_expr)
82
- import Sql.Query exposing (Q, field, from, join, select, where)
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 -> Q(Selector(Visit))
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(to_expr("scheduled")))
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 two `Expr(a)` and yield `Expr(Bool)`;
111
- `is_null` / `is_not_null` take one; `and` joins two; `in_` matches a list.
112
- `now` is the DB clock (`now()`), for time comparisons:
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 (column, gt, now, to_expr)
228
+ import Sql exposing (db_now, gte)
229
+ import Sql.Expr as Expr
116
230
 
117
- a.starts_at |> gte(to_expr(cutoff)) # a.starts_at >= ?
118
- column("s", "expires_at") |> gt(now) # s.expires_at > now()
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
- `now` is `Expr(Instant)` — the *DB* transaction clock, not the app clock.
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.Mutation.timestamped` (below), which uses the app clock like Rails.
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 (column, count_all)
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 -> Q(Selector(VisitCount))
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(column("p", "name"))
262
+ |> group(p.name)
148
263
  |> order_desc(count_all)
149
- |> order(column("p", "name"))
264
+ |> order(p.name)
150
265
  end
151
266
  # ... GROUP BY p.name ORDER BY COUNT(*) DESC, p.name
152
267
  ```
153
268
 
154
- `HAVING` and `CASE` aren't built in yet for filtering aggregates or
155
- conditional expressions, fall back to the raw-SQL escape hatch
156
- (`execute_*`). Basic aggregates (`SUM`, `COUNT`) and the
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) -> Q(Selector(Visit))
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 MaybeAppointmentsCols; field types are Expr(Maybe(String)) etc.
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 `cast`
349
+ ### Phantom-type rewrap with `unsafe_cast`
206
350
 
207
- `cast(e: Expr(a)) -> Expr(b)` widens a column's phantom type — useful
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 |> cast) # Expr(String) → Expr(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; `cast` just teaches the SQL
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)), Expr(a)) -> Expr(a)` | `COALESCE(e, def)` | Drops the `Maybe` with a fallback. |
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, column, count_all, sum, to_expr)
390
+ import Sql exposing (coalesce, count_all, sum)
247
391
 
248
- select(Totals(_, _))
249
- |> field(count_all)
250
- |> field(coalesce(sum(column("a", "visit_no")), to_expr(0)))
251
- # SELECT COUNT(*), COALESCE(SUM(a.visit_no), ?)
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`, `HAVING`, and arithmetic, fall back to the raw-`Expr`
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, column)
477
+ import Sql exposing (array_overlaps)
478
+ import Sql.Query exposing (filter, from)
277
479
 
278
- def filter_by_tags(selected: List(String)) -> Expr(Bool)
279
- array_overlaps(column("a", "tags"), selected)
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(column("a", "tags")) |> eq(to_expr(0))`.
492
+ `array_length(a.tags) |> eq(0)`.
288
493
 
289
- Mutation ops for partial array updates:
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(to_expr(aid)) },
303
- (a) -> { [a.tags |> set_(array_append(a.tags, new_tag))] },
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 @? ?::jsonpath` |
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 (column, jsonb_contains, jsonb_path_exists)
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(column("r", "meta"), KindMatch(k))
537
+ def matches_kind(k: String) -> (ReferralsCols -> Expr(Bool))
538
+ (r) -> { jsonb_contains(r.meta, KindMatch(k)) }
328
539
  end
329
540
 
330
- # WHERE r.meta @? ?::jsonpath (param: "$.priority ? (@ > 1)")
331
- def has_priority_gt(path: String) -> Expr(Bool)
332
- jsonb_path_exists(column("r", "meta"), path)
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
- The `@?` operator requires `jsonpath` on the right; the param binds as
337
- text and gets cast at the SQL level.
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 mutations
556
+ ## Build writes
340
557
 
341
- Define codec interfaces for your domain type:
558
+ One interface says which columns a value writes:
342
559
 
343
560
  ```jade
344
- import Sql exposing(Assignment, SqlMapper, Identified, assign)
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 SqlMapper(Patient) with
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
- def encode_patient_pk(p: Patient) -> List(Value)
366
- [encode(p.id)]
367
- end
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 mutation API works on values directly:
609
+ Then the write API works on values directly:
376
610
 
377
611
  ```jade
378
- import Sql.Mutation exposing(insert, update, delete, insert_all, update_all, delete_all, to_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(to_expr("scheduled")) },
388
- (a) -> { [a.cancelled |> set_(to_expr(True))] })
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(to_expr(True)) })
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
- `returning` is the mutation-side counterpart to `select` for queries.
399
- It takes a closure that receives the table's column accessors and
400
- builds a Q-wrapped selector projecting them into a target type. The
401
- Q wrapper is just to share the same `select`/`field` builders as
402
- queries `returning` extracts the inner `Selector` and discards the
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.Mutation exposing(insert, returning, to_sql)
692
+ import Sql.Write exposing(insert, returning_with, to_sql)
409
693
 
410
- # INSERT INTO patients (name, mrn) VALUES (?, ?) RETURNING id, name, mrn
694
+ # INSERT INTO patients (name, mrn) VALUES (?, ?)
695
+ # RETURNING patients.id, patients.name, patients.mrn
411
696
  np
412
697
  |> insert(patients)
413
- |> returning((p) -> {
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
- Bonus: the projector can be defined once and shared between SELECT
423
- queries and RETURNING — both contexts now take the same `cols ->
424
- Q(Selector(target))` shape, so a single `def patient_projector(p)`
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 create(np: NewPatient) -> Task(Patient, SqlError)
433
- np |> insert(patients) |> returning((p) -> {
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
- `insert` / `update` / `delete` need `SqlMapper(a)` + `Identified(a)`.
444
- `insert_all` needs only `SqlMapper`. `update_all`/`delete_all` build
445
- the SET / WHERE clauses directly from the column accessors no codec.
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
- `SqlMapper` is also implemented for `List(Assignment)` itself, so you
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` + `updated_at` on insert, `updated_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.Mutation exposing (insert, timestamped, update)
755
+ import Sql.Write exposing (insert, timestamped, update)
466
756
 
467
- new_patient |> insert(patients) |> timestamped |> execute -- both set
468
- patient |> update(patients) |> timestamped |> execute -- updated_at only
469
- new_import |> insert(patients) |> execute -- no timestamps
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