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.
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)` 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) -> 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,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)), 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` 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, column)
479
+ import Sql exposing (array_overlaps)
480
+ import Sql.Query exposing (filter, from)
277
481
 
278
- def filter_by_tags(selected: List(String)) -> Expr(Bool)
279
- array_overlaps(column("a", "tags"), selected)
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(column("a", "tags")) |> eq(to_expr(0))`.
494
+ `array_length(a.tags) |> eq(0)`.
288
495
 
289
- Mutation ops for partial array updates:
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(to_expr(aid)) },
303
- (a) -> { [a.tags |> set_(array_append(a.tags, new_tag))] },
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 @? ?::jsonpath` |
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 (column, jsonb_contains, jsonb_path_exists)
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(column("r", "meta"), KindMatch(k))
539
+ def matches_kind(k: String) -> (ReferralsCols -> Expr(Bool))
540
+ (r) -> { jsonb_contains(r.meta, KindMatch(k)) }
328
541
  end
329
542
 
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)
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
- The `@?` operator requires `jsonpath` on the right; the param binds as
337
- text and gets cast at the SQL level.
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 mutations
558
+ ## Build writes
340
559
 
341
- Define codec interfaces for your domain type:
560
+ One interface says which columns a value writes:
342
561
 
343
562
  ```jade
344
- import Sql exposing(Assignment, SqlMapper, Identified, assign)
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 SqlMapper(Patient) with
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
- def encode_patient_pk(p: Patient) -> List(Value)
366
- [encode(p.id)]
367
- end
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 mutation API works on values directly:
611
+ Then the write API works on values directly:
376
612
 
377
613
  ```jade
378
- import Sql.Mutation exposing(insert, update, delete, insert_all, update_all, delete_all, to_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(to_expr("scheduled")) },
388
- (a) -> { [a.cancelled |> set_(to_expr(True))] })
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(to_expr(True)) })
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 mutation-side counterpart to `select` for queries.
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 Q-wrapped selector projecting them into a target type. The
401
- Q wrapper is just to share the same `select`/`field` builders as
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 Q state.
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.Mutation exposing(insert, returning, to_sql)
695
+ import Sql.Write exposing(insert, returning, to_sql)
409
696
 
410
- # INSERT INTO patients (name, mrn) VALUES (?, ?) RETURNING id, name, mrn
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
- Q(Selector(target))` shape, so a single `def patient_projector(p)`
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
- `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 accessorsno codec.
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 oneenough 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
- `SqlMapper` is also implemented for `List(Assignment)` itself, so you
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` + `updated_at` on insert, `updated_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.Mutation exposing (insert, timestamped, update)
759
+ import Sql.Write exposing (insert, timestamped, update)
466
760
 
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
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