jade-sql 0.7.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.
@@ -0,0 +1,625 @@
1
+ module Sql.Write exposing (
2
+ Action(..),
3
+ Conflict(..),
4
+ do_nothing,
5
+ do_update,
6
+ Write,
7
+ on_conflict,
8
+ delete,
9
+ execute,
10
+ fetch_many,
11
+ fetch_one,
12
+ delete_all,
13
+ insert,
14
+ insert_all,
15
+ Stamped,
16
+ returning,
17
+ timestamped,
18
+ to_sql,
19
+ update,
20
+ filter,
21
+ update_all,
22
+ update_many,
23
+ )
24
+
25
+ import Sql exposing (
26
+ Assignable,
27
+ Assignment(..),
28
+ Expr(..),
29
+ ToSql,
30
+ Selector(..),
31
+ SqlError,
32
+ Pk,
33
+ Table,
34
+ Unique,
35
+ TableRef(..),
36
+ set,
37
+ to_assigns,
38
+ )
39
+ import Sql.Query exposing (Query)
40
+ import Decode exposing (Value)
41
+ import Encode
42
+
43
+
44
+ type WriteKind
45
+ = InsertK
46
+ | UpdateK
47
+ | DeleteK
48
+
49
+
50
+ struct Write(ret, c, s) = {
51
+ kind: WriteKind,
52
+ table: TableRef,
53
+ cols: c,
54
+ set_cols: s,
55
+ rows: List(List(Assignment)),
56
+ wheres: List(Expr(Bool)),
57
+ returning_selector: Selector(ret),
58
+ from_: Maybe(Expr(Bool)),
59
+ conflict: Maybe(Conflict)
60
+ }
61
+
62
+
63
+ # `ON CONFLICT (cols) DO NOTHING` or `DO UPDATE SET ...`. The columns are the
64
+ # conflict target, taken from a `Unique`, so the index it names is the one the
65
+ # database will actually use to detect the conflict.
66
+ type Conflict
67
+ = DoNothing(List(String))
68
+ | DoUpdate(List(String), List(Assignment))
69
+
70
+
71
+ # What to do with a row that collides, as a value: `do_nothing`, or
72
+ # `do_update` with what to write instead. Both name the SQL they render.
73
+ type Action(s)
74
+ = DoNothingA
75
+ | DoUpdateA(s -> List(Assignment))
76
+
77
+
78
+ def do_nothing -> Action(s)
79
+ DoNothingA
80
+ end
81
+
82
+
83
+ # The build receives the table's `SET` columns. Reach for `set_excluded` to
84
+ # take a value from the row that could not be inserted.
85
+ def do_update(build: s -> List(Assignment)) -> Action(s)
86
+ DoUpdateA(build)
87
+ end
88
+
89
+
90
+ def insert(value: a, t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
91
+ Write(
92
+ InsertK,
93
+ TableRef(t.name, t.alias_),
94
+ t.alias_ |> t.cols,
95
+ t.set_cols,
96
+ [to_assigns(value)],
97
+ [],
98
+ Selector([], []),
99
+ Nothing,
100
+ Nothing,
101
+ )
102
+ end
103
+
104
+
105
+ def insert_all(values: List(a), t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
106
+ Write(
107
+ InsertK,
108
+ TableRef(t.name, t.alias_),
109
+ t.alias_ |> t.cols,
110
+ t.set_cols,
111
+ List.map(values, (v) -> { to_assigns(v) }),
112
+ [],
113
+ Selector([], []),
114
+ Nothing,
115
+ Nothing,
116
+ )
117
+ end
118
+
119
+
120
+ def update(value: a, t: Table(c, m, k, o, r, s), key: k) -> Write(Int, c, s)
121
+ Write(
122
+ UpdateK,
123
+ TableRef(t.name, t.alias_),
124
+ t.alias_ |> t.cols,
125
+ t.set_cols,
126
+ [without_created_at(without_key(t.pk, to_assigns(value)))],
127
+ [key_predicate(t.pk, key)],
128
+ Selector([], []),
129
+ Nothing,
130
+ Nothing,
131
+ )
132
+ end
133
+
134
+
135
+ def delete(t: Table(c, m, k, o, r, s), key: k) -> Write(Int, c, s)
136
+ Write(
137
+ DeleteK,
138
+ TableRef(t.name, t.alias_),
139
+ t.alias_ |> t.cols,
140
+ t.set_cols,
141
+ [],
142
+ [key_predicate(t.pk, key)],
143
+ Selector([], []),
144
+ Nothing,
145
+ Nothing,
146
+ )
147
+ end
148
+
149
+
150
+ def filter(m: Write(ret, c, s), build: c -> Expr(Bool)) -> Write(ret, c, s)
151
+ Write(
152
+ m.kind,
153
+ m.table,
154
+ m.cols,
155
+ m.set_cols,
156
+ m.rows,
157
+ m.wheres ++ [m.cols |> build],
158
+ m.returning_selector,
159
+ m.from_,
160
+ m.conflict,
161
+ )
162
+ end
163
+
164
+
165
+ def update_all(
166
+ t: Table(c, m, k, o, r, s),
167
+ pred_fn: c -> Expr(Bool),
168
+ build: c, s -> List(Assignment),
169
+ ) -> Write(Int, c, s)
170
+ cols_ = t.alias_ |> t.cols
171
+ pred = cols_ |> pred_fn
172
+
173
+ Write(
174
+ UpdateK,
175
+ TableRef(t.name, t.alias_),
176
+ cols_,
177
+ t.set_cols,
178
+ [build(cols_, t.set_cols)],
179
+ [pred],
180
+ Selector([], []),
181
+ Nothing,
182
+ Nothing,
183
+ )
184
+ end
185
+
186
+
187
+ def update_many(rows_: List((k, a)), t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
188
+ rows = rows_ |> List.map((r) -> { keyed_assigns(t.pk, r) })
189
+
190
+ Write(
191
+ UpdateK,
192
+ TableRef(t.name, t.alias_),
193
+ t.alias_ |> t.cols,
194
+ t.set_cols,
195
+ [source_assigns(t.pk, rows)],
196
+ [pk_join(t.alias_, t.pk.columns)],
197
+ Selector([], []),
198
+ Just(source_of(t.name, rows)),
199
+ Nothing,
200
+ )
201
+ end
202
+
203
+
204
+ # The key travels with each row so the JSON source carries the columns the
205
+ # join matches on, without the caller's struct having to hold them.
206
+ def keyed_assigns(pk_: Pk(c, k), r: (k, a)) -> List(Assignment)
207
+ (key, value) = r
208
+
209
+ key_assigns(pk_, key) ++ without_created_at(without_key(pk_, to_assigns(value)))
210
+ end
211
+
212
+
213
+ def key_assigns(pk_: Pk(c, k), key: k) -> List(Assignment)
214
+ List.zip(pk_.columns, key |> pk_.values)
215
+ |> List.map((p) -> { Assignment(Tuple.first(p), "?", [Tuple.second(p)]) })
216
+ end
217
+
218
+
219
+ # One JSON parameter carries every row, so the statement is the same size
220
+ # for two rows or two hundred. json_populate_recordset gives the record
221
+ # the table's own column types, which is what makes a bare `?` legal in a
222
+ # position Postgres cannot otherwise infer.
223
+ def source_of(name: String, rows: List(List(Assignment))) -> Expr(Bool)
224
+ Expr(
225
+ "json_populate_recordset(null::" ++ name ++ ", ?::json) AS jade_src",
226
+ [Encode.string(Encode.encode_to_string(json_rows(rows)))],
227
+ )
228
+ end
229
+
230
+
231
+ def json_rows(rows: List(List(Assignment))) -> Value
232
+ Encode.list(json_row(_), rows)
233
+ end
234
+
235
+
236
+ def json_row(row: List(Assignment)) -> Value
237
+ Encode.object(row |> List.map((a) -> { Tuple.Tuple2(a.col, first_param(a)) }))
238
+ end
239
+
240
+
241
+ def first_param(a: Assignment) -> Value
242
+ a.params |> List.head |> Maybe.with_default(Encode.null)
243
+ end
244
+
245
+
246
+ def source_assigns(pk_: Pk(c, k), rows: List(List(Assignment))) -> List(Assignment)
247
+ rows
248
+ |> touched_cols
249
+ |> List.filter((col) -> { List.member?(pk_.columns, col) |> Basics.not })
250
+ |> List.map((col) -> { Assignment(col, "jade_src." ++ col, []) })
251
+ end
252
+
253
+
254
+ def touched_cols(rows: List(List(Assignment))) -> List(String)
255
+ rows
256
+ |> List.concat
257
+ |> List.map(.col)
258
+ |> List.fold([], (seen, c) -> { List.member?(seen, c) ? seen : seen ++ [c] })
259
+ end
260
+
261
+
262
+ def pk_join(alias_: String, pk_columns: List(String)) -> Expr(Bool)
263
+ Expr(
264
+ pk_columns
265
+ |> List.map((col) -> { alias_ ++ "." ++ col ++ " = jade_src." ++ col })
266
+ |> String.join(" AND "),
267
+ [],
268
+ )
269
+ end
270
+
271
+
272
+ def delete_all(t: Table(c, m, k, o, r, s), pred_fn: c -> Expr(Bool)) -> Write(Int, c, s)
273
+ cols_ = t.alias_ |> t.cols
274
+ pred = cols_ |> pred_fn
275
+
276
+ Write(
277
+ DeleteK,
278
+ TableRef(t.name, t.alias_),
279
+ cols_,
280
+ t.set_cols,
281
+ [],
282
+ [pred],
283
+ Selector([], []),
284
+ Nothing,
285
+ Nothing,
286
+ )
287
+ end
288
+
289
+
290
+ # `ON CONFLICT (email) DO NOTHING`, or `... DO UPDATE SET ...`. The target
291
+ # comes from a `Unique`, so the index named is one the database has, and the
292
+ # action says which of the two it is:
293
+ #
294
+ # insert(row, users) |> on_conflict(users_email_key, do_nothing)
295
+ # insert(row, users) |> on_conflict(users_email_key, do_update((s) -> { ... }))
296
+ def on_conflict(m: Write(ret, c, s), u: Unique(c, uk), action: Action(s)) -> Write(ret, c, s)
297
+ case action
298
+ in DoNothingA then { m | conflict: Just(DoNothing(u.columns)) }
299
+
300
+ in DoUpdateA(build)
301
+ then { m | conflict: Just(DoUpdate(u.columns, build(m.set_cols))) }
302
+ end
303
+ end
304
+
305
+
306
+ def returning(m: Write(a, c, s), build: c -> Query(Selector(b))) -> Write(b, c, s)
307
+ Write(
308
+ m.kind,
309
+ m.table,
310
+ m.cols,
311
+ m.set_cols,
312
+ m.rows,
313
+ m.wheres,
314
+ build(m.cols).result,
315
+ m.from_,
316
+ m.conflict,
317
+ )
318
+ end
319
+
320
+
321
+ # A value plus `created_at` and `updated_at`, filled with the app clock at
322
+ # execute time. A table declaring those columns NOT NULL requires them of the
323
+ # value being written, and this is what supplies them:
324
+ #
325
+ # insert(NewPatient("Ada") |> timestamped, patients)
326
+ # update(Patch(name) |> timestamped, patients, id)
327
+ #
328
+ # `update` writes only `updated_at`, since the row already has the other. The
329
+ # struct being wrapped keeps its own `Assignable`; the stamps are appended to
330
+ # whatever it writes.
331
+ struct Stamped(a) = { value: a }
332
+
333
+
334
+ def timestamped(value: a) -> Stamped(a)
335
+ Stamped(value)
336
+ end
337
+
338
+
339
+ implements Assignable(Stamped(a)) with
340
+ to_assigns: (s) -> {
341
+ to_assigns(s.value) ++ [now_assignment("created_at"), now_assignment("updated_at")]
342
+ },
343
+ end
344
+
345
+
346
+ def now_assignment(col: String) -> Assignment
347
+ Assignment(col, "$JADE_SQL_NOW$", [])
348
+ end
349
+
350
+
351
+ # Only the one `timestamped` added: it writes the clock token rather than a
352
+ # parameter, so a `created_at` the caller wrote by hand still reaches the row.
353
+ def without_created_at(assigns: List(Assignment)) -> List(Assignment)
354
+ assigns |> List.filter((a) -> { Basics.not(added_created_at?(a)) })
355
+ end
356
+
357
+
358
+ def added_created_at?(a: Assignment) -> Bool
359
+ a.col == "created_at" && a.value_sql == "$JADE_SQL_NOW$"
360
+ end
361
+
362
+
363
+ def without_key(pk_: Pk(c, k), assigns: List(Assignment)) -> List(Assignment)
364
+ assigns |> List.filter((a) -> { List.member?(pk_.columns, a.col) |> Basics.not })
365
+ end
366
+
367
+
368
+ # The `WHERE` that selects one row by its key: `id = ?`, or
369
+ # `user_id = ? AND group_id = ?` for a composite key. Columns and values both
370
+ # come from the table's `Pk`, in the order `structure.sql` declares them, so a
371
+ # caller supplies a `k` and never a column name or an ordering.
372
+ def key_predicate(pk_: Pk(c, k), key: k) -> Expr(Bool)
373
+ sql = pk_.columns
374
+ |> List.map((col) -> { col ++ " = ?" })
375
+ |> String.join(" AND ")
376
+
377
+ Expr(sql, key |> pk_.values)
378
+ end
379
+
380
+
381
+ def render_row(row: List(Assignment)) -> String
382
+ cols_part = row
383
+ |> List.map((a) -> { a.value_sql })
384
+ |> String.join(", ")
385
+
386
+ "(" ++ cols_part ++ ")"
387
+ end
388
+
389
+
390
+ def returning_clause(s: Selector(ret)) -> String
391
+ List.empty?(s.columns_sql) ? "" : " RETURNING " ++ String.join(s.columns_sql, ", ")
392
+ end
393
+
394
+
395
+ def insert_cols_str(rows: List(List(Assignment))) -> String
396
+ case rows
397
+ in [] then ""
398
+ in [first | _]
399
+ "(" ++ String.join(List.map(first, (a) -> { a.col }), ", ") ++ ")"
400
+ end
401
+ end
402
+
403
+
404
+ def insert_params(rows: List(List(Assignment))) -> List(Value)
405
+ List.fold(
406
+ rows,
407
+ [],
408
+ (acc, row) -> { acc ++ List.fold(row, [], (acc2, a) -> { acc2 ++ a.params }) },
409
+ )
410
+ end
411
+
412
+
413
+ def set_sqls_for(rows: List(List(Assignment))) -> List(String)
414
+ case rows
415
+ in [first | _] then List.map(first, (a) -> { a.col ++ " = " ++ a.value_sql })
416
+ in [] then []
417
+ end
418
+ end
419
+
420
+
421
+ def set_params_for(rows: List(List(Assignment))) -> List(Value)
422
+ case rows
423
+ in [first | _] then List.fold(first, [], (acc, a) -> { acc ++ a.params })
424
+ in [] then []
425
+ end
426
+ end
427
+
428
+
429
+ # The target carries the same alias the column accessors were built with, so
430
+ # a predicate reads the same in a write as it does in a query and nothing has
431
+ # to be rewritten after the fact. It is also what keeps `id = jade_src.id`
432
+ # unambiguous under a FROM, since json_populate_recordset gives the source
433
+ # every column the table has.
434
+ def target(m: Write(ret, c, s)) -> String
435
+ m.table.alias_ == m.table.name
436
+ ? m.table.name
437
+ : m.table.name ++ " AS " ++ m.table.alias_
438
+ end
439
+
440
+
441
+ def from_clause(from_: Maybe(Expr(Bool))) -> String
442
+ case from_
443
+ in Nothing then ""
444
+ in Just(e) then " FROM " ++ e.sql
445
+ end
446
+ end
447
+
448
+
449
+ def from_params(from_: Maybe(Expr(Bool))) -> List(Value)
450
+ case from_
451
+ in Nothing then []
452
+ in Just(e) then e.params
453
+ end
454
+ end
455
+
456
+
457
+ def where_sql(wheres: List(Expr(Bool))) -> String
458
+ List.empty?(wheres)
459
+ ? ""
460
+ : " WHERE " ++ String.join(List.map(wheres, (w) -> { w.sql }), " AND ")
461
+ end
462
+
463
+
464
+ def where_params(wheres: List(Expr(Bool))) -> List(Value)
465
+ List.fold(wheres, [], (acc, w) -> { acc ++ w.params })
466
+ end
467
+
468
+
469
+ def render_insert(m: Write(ret, c, s)) -> (String, List(Value))
470
+ List.empty?(m.rows) ? render_nothing : render_insert_rows(m)
471
+ end
472
+
473
+
474
+ def render_insert_rows(m: Write(ret, c, s)) -> (String, List(Value))
475
+ sql = "INSERT INTO "
476
+ ++ target(m)
477
+ ++ " "
478
+ ++ insert_body(m.rows)
479
+ ++ conflict_clause(m.conflict)
480
+ ++ returning_clause(m.returning_selector)
481
+
482
+ Tuple.Tuple2(
483
+ sql,
484
+ insert_params(m.rows) ++ conflict_params(m.conflict) ++ m.returning_selector.params,
485
+ )
486
+ end
487
+
488
+
489
+ def conflict_clause(c: Maybe(Conflict)) -> String
490
+ case c
491
+ in Nothing then ""
492
+
493
+ in Just(DoNothing(cols)) then
494
+ " ON CONFLICT (" ++ String.join(cols, ", ") ++ ") DO NOTHING"
495
+
496
+ in Just(DoUpdate(cols, assigns)) then
497
+ " ON CONFLICT ("
498
+ ++ String.join(cols, ", ")
499
+ ++ ") DO UPDATE SET "
500
+ ++ String.join(List.map(assigns, (a) -> { a.col ++ " = " ++ a.value_sql }), ", ")
501
+ end
502
+ end
503
+
504
+
505
+ def conflict_params(c: Maybe(Conflict)) -> List(Value)
506
+ case c
507
+ in Just(DoUpdate(_, assigns)) then
508
+ List.fold(assigns, [], (acc, a) -> { acc ++ a.params })
509
+
510
+ else []
511
+ end
512
+ end
513
+
514
+
515
+ # `DEFAULT VALUES` for the one row that names no columns: that is a row of
516
+ # defaults, which is what was asked for, and `() VALUES ()` is not SQL. Not
517
+ # the same as having no rows at all, which writes nothing.
518
+ def insert_body(rows: List(List(Assignment))) -> String
519
+ case rows
520
+ in [[]] then "DEFAULT VALUES"
521
+
522
+ else insert_cols_str(rows)
523
+ ++ " VALUES "
524
+ ++ String.join(List.map(rows, render_row), ", ")
525
+ end
526
+ end
527
+
528
+
529
+ def render_update(m: Write(ret, c, s)) -> (String, List(Value))
530
+ sets = set_sqls_for(m.rows)
531
+
532
+ List.empty?(sets) ? render_nothing : render_update_set(m, sets)
533
+ end
534
+
535
+
536
+ # A write with nothing to write. A changeset whose fields all held their old
537
+ # values, and an empty batch, are both ordinary — so this affects no rows and
538
+ # reports so, rather than failing.
539
+ #
540
+ # It has to be a statement, because `to_sql` owes its caller one. `WHERE FALSE`
541
+ # is the whole point: the previous no-op update kept the caller's predicate and
542
+ # rendered `SELECT ... WHERE id = ?`, which `exec_update` counts as a row
543
+ # updated. It matched, so it reported 1, took no lock, and a `returning` read
544
+ # handed back the row as it stood before the update that never happened.
545
+ #
546
+ # The predicate's parameters go with it. A statement that does not bind them
547
+ # must not carry them.
548
+ def render_nothing -> (String, List(Value))
549
+ Tuple.Tuple2("SELECT 1 WHERE FALSE", [])
550
+ end
551
+
552
+
553
+ def render_update_set(
554
+ m: Write(ret, c, s),
555
+ sets: List(String),
556
+ ) -> (String, List(Value))
557
+ set_clause = "SET " ++ String.join(sets, ", ")
558
+ where = where_sql(m.wheres)
559
+ returning_ = returning_clause(m.returning_selector)
560
+ sql = "UPDATE "
561
+ ++ target(m)
562
+ ++ " "
563
+ ++ set_clause
564
+ ++ from_clause(m.from_)
565
+ ++ where
566
+ ++ returning_
567
+
568
+ Tuple.Tuple2(
569
+ sql,
570
+ set_params_for(m.rows)
571
+ ++ from_params(m.from_)
572
+ ++ where_params(m.wheres)
573
+ ++ m.returning_selector.params,
574
+ )
575
+ end
576
+
577
+
578
+
579
+
580
+ def render_delete(m: Write(ret, c, s)) -> (String, List(Value))
581
+ where = where_sql(m.wheres)
582
+ returning_ = returning_clause(m.returning_selector)
583
+ sql = "DELETE FROM "
584
+ ++ target(m)
585
+ ++ where
586
+ ++ returning_
587
+
588
+ Tuple.Tuple2(sql, where_params(m.wheres) ++ m.returning_selector.params)
589
+ end
590
+
591
+
592
+ def to_sql(m: Write(ret, c, s)) -> (String, List(Value))
593
+ case m.kind
594
+ in InsertK then render_insert(m)
595
+ in UpdateK then render_update(m)
596
+ in DeleteK then render_delete(m)
597
+ end
598
+ end
599
+
600
+
601
+ implements ToSql(Write(ret, c, s)) with
602
+ to_sql: to_sql
603
+ end
604
+
605
+
606
+ # The number of rows the statement affected. Any `RETURNING` clause is
607
+ # rendered and discarded.
608
+ def execute(m: Write(ret, c, s)) -> Task(Int, SqlError)
609
+ to_sql(m) |> Sql.execute_raw
610
+ end
611
+
612
+
613
+ # Decodes the single row a `returning` write gives back — the id of an
614
+ # insert, the new state of an update. A write with no `returning` selects
615
+ # nothing, so this errors with `NotFound`.
616
+ def fetch_one(m: Write(ret, c, s)) -> Task(ret, SqlError)
617
+ to_sql(m) |> Sql.fetch_one_raw
618
+ end
619
+
620
+
621
+ # The same for a write that returns many rows, as `insert_all` and
622
+ # `update_all` do.
623
+ def fetch_many(m: Write(ret, c, s)) -> Task(List(ret), SqlError)
624
+ to_sql(m) |> Sql.fetch_many_raw
625
+ end