jade-sql 0.9.1 → 0.10.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/docs/building.md +101 -19
- data/docs/running.md +13 -14
- data/lib/jade-sql/bin/generate_schema.rb +14 -6
- data/lib/jade-sql/runtime.rb +8 -0
- data/lib/jade-sql/sql/expr.jd +218 -0
- data/lib/jade-sql/sql/json.jd +4 -5
- data/lib/jade-sql/sql/query.jd +33 -66
- data/lib/jade-sql/sql/write.jd +20 -12
- data/lib/jade-sql/sql.jd +112 -2
- data/lib/jade-sql/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a375c4f1e9f8a4e724d4cfdb8019c47781be6fa151d63365dc217e0166d1cc3
|
|
4
|
+
data.tar.gz: cf7c46f4b57d31144dd38b15bf3471c437be7e0c3ae1315d1c5364de5a81f68b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef176c28d0a9d6dd4beb054940cc639ddf460bab7d05fc96cd9172b4d3f6841b855ea6b5e8aa4c66e1a83642a1167f412636dba1bf508d1a9c121e3708b50ae3
|
|
7
|
+
data.tar.gz: f54a7d82ec1d8c95441f5856a26298c6b8561dc4466b0ebab2820ac60b98fc7e2257c2d468016c5cb00021e262543f8183436970257d5ab07056b9658efa112e
|
data/docs/building.md
CHANGED
|
@@ -56,10 +56,32 @@ type VisitStatus
|
|
|
56
56
|
| Done
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
59
|
+
The module carries a codec written from the labels, so `eq(v.status,
|
|
60
|
+
"schedulled")` stops compiling, and a `Finite` instance listing every variant.
|
|
61
|
+
That list is what lets `Expr.per_variant` give a `CASE` one arm per value:
|
|
62
|
+
|
|
63
|
+
```jade
|
|
64
|
+
v.status
|
|
65
|
+
|> Expr.per_variant((s) -> {
|
|
66
|
+
case s
|
|
67
|
+
in Scheduled then val("upcoming")
|
|
68
|
+
in InProgress then val("now")
|
|
69
|
+
in Done then val("past")
|
|
70
|
+
end
|
|
71
|
+
})
|
|
72
|
+
# (CASE v.status WHEN ? THEN ? WHEN ? THEN ? WHEN ? THEN ? END)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The arms come from a jade `case`, so a missing one is a compile error, and a
|
|
76
|
+
label added to the enum stops every `per_variant` that doesn't cover it
|
|
77
|
+
until it does. The branch runs once per value while the query is built, not
|
|
78
|
+
once per row. `Bool` has an instance too; anything else — a number, a string,
|
|
79
|
+
a nullable column — has no list of values to exhaust, so the compiler asks
|
|
80
|
+
for `case_of` instead: `No implementation of Sql.Expr.Finite for Int`.
|
|
81
|
+
|
|
82
|
+
A NULL scrutinee matches no arm and makes the whole `CASE` NULL. A nullable
|
|
83
|
+
column belongs in `case_when` with `is_null`, where that case is written
|
|
84
|
+
down.
|
|
63
85
|
|
|
64
86
|
`bytea` isn't mapped yet, though jade's `Bytes` is the natural target. See
|
|
65
87
|
jade-lang's `Decimal` for the full API (`of`/`scaled`/`parse`, arithmetic,
|
|
@@ -168,9 +190,8 @@ carries `NoJoins`.
|
|
|
168
190
|
|
|
169
191
|
A column whose name is a Jade keyword (e.g. `type`) gets a trailing
|
|
170
192
|
underscore in the struct field (`type_`) while the SQL column reference
|
|
171
|
-
keeps the real name.
|
|
172
|
-
|
|
173
|
-
(`SELECT … AS type_`), so reads round-trip without hand-written SQL:
|
|
193
|
+
keeps the real name. The generator also emits a `<table>_row` projector
|
|
194
|
+
that reads every column into the table's `Row` struct:
|
|
174
195
|
|
|
175
196
|
```jade
|
|
176
197
|
def entries -> Select(JournalEntriesRow)
|
|
@@ -179,10 +200,14 @@ def entries -> Select(JournalEntriesRow)
|
|
|
179
200
|
end
|
|
180
201
|
```
|
|
181
202
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
A projection is read back by position: each `field` fills the constructor
|
|
204
|
+
argument in the same place, which is the order the type-checker matched
|
|
205
|
+
them in. A column's name never has to match its field, so a renamed column,
|
|
206
|
+
a computed one and two `id`s from a join all read the same way:
|
|
207
|
+
|
|
208
|
+
```jade
|
|
209
|
+
select(Visits(_)) |> field(count_all) # lands in whatever Visits calls it
|
|
210
|
+
```
|
|
186
211
|
|
|
187
212
|
## Build queries
|
|
188
213
|
|
|
@@ -380,8 +405,31 @@ builder — params stitch in declaration order automatically.
|
|
|
380
405
|
| `sum(Expr(Int)) -> Expr(Maybe(Int))` | `SUM(e)` | `NULL` on empty group → `Maybe`. |
|
|
381
406
|
| `count(Expr(a)) -> Expr(Int)` | `COUNT(e)` | Counts non-null rows for the column. |
|
|
382
407
|
| `count_all -> Expr(Int)` | `COUNT(*)` | Total row count. |
|
|
408
|
+
| `count_distinct(Expr(a)) -> Expr(Int)` | `COUNT(DISTINCT e)` | Distinct non-null values. |
|
|
409
|
+
| `max`, `min(Expr(a)) -> Expr(Maybe(a))` | `MAX(e)`, `MIN(e)` | `NULL` on empty group. |
|
|
410
|
+
| `avg(Expr(Int)) -> Expr(Maybe(Decimal))` | `AVG(e)` | Postgres averages integers exactly. |
|
|
383
411
|
| `coalesce(Expr(Maybe(a)), a) -> Expr(a)` | `COALESCE(e, ?)` | Drops the `Maybe` with a fallback. |
|
|
384
412
|
| `neg(Expr(Int)) -> Expr(Int)` | `-(e)` | Unary minus. |
|
|
413
|
+
| `lower`, `upper(Expr(String)) -> Expr(String)` | `LOWER(e)` | |
|
|
414
|
+
| `trunc_date(DateUnit, Expr(Date)) -> Expr(Date)` | `date_trunc('month', e)` | First day of the unit. |
|
|
415
|
+
| `json_text(Expr(Value), String) -> Expr(Maybe(String))` | `e ->> ?` | `NULL` where the key is absent. |
|
|
416
|
+
|
|
417
|
+
`Sql.Expr` holds the ones that take a second expression rather than a value:
|
|
418
|
+
|
|
419
|
+
| Function | SQL |
|
|
420
|
+
|-------------------------------------------------|-----------------------------|
|
|
421
|
+
| `plus`, `minus`, `times`, `div` on `Expr(Int)` | `(a + b)`; `div` truncates |
|
|
422
|
+
| `concat(Expr(String), Expr(String))` | `(a \|\| b)` |
|
|
423
|
+
| `plus_days(Expr(Date), Expr(Int))` | `(a + b)` |
|
|
424
|
+
| `coalesce(Expr(Maybe(a)), Expr(a))` | `COALESCE(a, b)` |
|
|
425
|
+
| `greatest`, `least(Expr(a), Expr(a))` | `GREATEST(a, b)` |
|
|
426
|
+
| `filtered_to(Expr(a), Expr(Bool))` | `agg FILTER (WHERE cond)` |
|
|
427
|
+
| `case_when` / `when` / `otherwise` | `CASE WHEN … ELSE … END` |
|
|
428
|
+
| `per_variant(Expr(a), a -> Expr(b))` | `CASE e WHEN … END` |
|
|
429
|
+
| `case_of` / `when_eq` / `otherwise` | `CASE e WHEN … ELSE … END` |
|
|
430
|
+
|
|
431
|
+
A value you hold goes in through `val`, so each operation has one function:
|
|
432
|
+
`p.balance |> Expr.times(val(12))`.
|
|
385
433
|
|
|
386
434
|
Worked example — count visits and the most recent visit number,
|
|
387
435
|
coalesced to 0 when a patient has none:
|
|
@@ -399,8 +447,42 @@ end
|
|
|
399
447
|
# SELECT COUNT(*), COALESCE(SUM(a.visit_no), ?) FROM appointments a
|
|
400
448
|
```
|
|
401
449
|
|
|
402
|
-
|
|
403
|
-
|
|
450
|
+
Three CASE spellings, by what the arms match on. Each builder takes its first
|
|
451
|
+
arm, and a `Case` is not an expression until `otherwise` closes it, so neither
|
|
452
|
+
a CASE without arms nor one without an `ELSE` can be written.
|
|
453
|
+
|
|
454
|
+
Conditions, where nothing can list every case:
|
|
455
|
+
|
|
456
|
+
```jade
|
|
457
|
+
case_when(p.balance |> Expr.lt(val(0)), val("owes"))
|
|
458
|
+
|> Expr.when(p.balance |> Expr.gt(val(100)), val("credit"))
|
|
459
|
+
|> Expr.otherwise(val("even"))
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
A value, where you care about some of what it can be:
|
|
463
|
+
|
|
464
|
+
```jade
|
|
465
|
+
case_of(p.tier, val(1), val("gold"))
|
|
466
|
+
|> Expr.when_eq(val(2), val("silver"))
|
|
467
|
+
|> Expr.otherwise(val("basic"))
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Both sides of `when_eq` are expressions of the same shape, so a mapping
|
|
471
|
+
written the wrong way round is still a mapping — read those arms.
|
|
472
|
+
|
|
473
|
+
A value whose every case you want, which is `per_variant` and needs no
|
|
474
|
+
`ELSE` (see [enums](#generate-schemajd-from-dbstructuresql)). Adding an
|
|
475
|
+
`otherwise` to a CASE over an enum is legal and loses that check: a label
|
|
476
|
+
added to the enum lands quietly in the fallback, where `per_variant` would
|
|
477
|
+
have stopped compiling.
|
|
478
|
+
|
|
479
|
+
One total per condition is not a CASE. `SUM(CASE WHEN kind = 'income' THEN
|
|
480
|
+
amount ELSE 0 END)` is `filtered_to`, which says the same thing to Postgres
|
|
481
|
+
without the arms:
|
|
482
|
+
|
|
483
|
+
```jade
|
|
484
|
+
sum(tl.amount_cents) |> Expr.filtered_to(eq(t.kind, Income))
|
|
485
|
+
```
|
|
404
486
|
|
|
405
487
|
### Subqueries
|
|
406
488
|
|
|
@@ -662,8 +744,8 @@ values directly, so this is for the positions that cannot — a constant field
|
|
|
662
744
|
in a projection or a JSON document:
|
|
663
745
|
|
|
664
746
|
```jade
|
|
665
|
-
select(Row(_, _)) |> field(c.id) |>
|
|
666
|
-
# SELECT patients.id, ?
|
|
747
|
+
select(Row(_, _)) |> field(c.id) |> field(val("patient"))
|
|
748
|
+
# SELECT patients.id, ? FROM patients patients
|
|
667
749
|
```
|
|
668
750
|
|
|
669
751
|
It binds rather than inlining, so a string with a quote in it is a parameter
|
|
@@ -680,7 +762,7 @@ a `SET` takes no alias.
|
|
|
680
762
|
|
|
681
763
|
### RETURNING
|
|
682
764
|
|
|
683
|
-
`
|
|
765
|
+
`returning` names the columns coming back, from the table's accessors.
|
|
684
766
|
It is a step of its own rather than something `fetch_one` does for you: a
|
|
685
767
|
write has one type whether you run it for a count or for a row, so folding the
|
|
686
768
|
projection into the runner would mean `execute` and `fetch_one` producing
|
|
@@ -689,13 +771,13 @@ different SQL from the same `Write`.
|
|
|
689
771
|
```jade
|
|
690
772
|
import Sql exposing(Selector)
|
|
691
773
|
import Sql.Query exposing(select, field)
|
|
692
|
-
import Sql.Write exposing(insert,
|
|
774
|
+
import Sql.Write exposing(insert, returning, to_sql)
|
|
693
775
|
|
|
694
776
|
# INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
695
777
|
# RETURNING patients.id, patients.name, patients.mrn
|
|
696
778
|
np
|
|
697
779
|
|> insert(patients)
|
|
698
|
-
|>
|
|
780
|
+
|> returning((p) -> {
|
|
699
781
|
select(Patient(_, _, _))
|
|
700
782
|
|> field(p.id)
|
|
701
783
|
|> field(p.name)
|
|
@@ -714,7 +796,7 @@ def patient_row(p: PatientsCols) -> Select(Patient)
|
|
|
714
796
|
end
|
|
715
797
|
```
|
|
716
798
|
|
|
717
|
-
`from(patients) |> patient_row` for the read, `
|
|
799
|
+
`from(patients) |> patient_row` for the read, `returning(patient_row)`
|
|
718
800
|
for the write. With row polymorphism a projection can span tables — one
|
|
719
801
|
`def just_id(c: { a | id: Expr(Int) })` serves every table with an `id`.
|
|
720
802
|
|
data/docs/running.md
CHANGED
|
@@ -55,19 +55,18 @@ fetch_values : Query(c), Expr(b) -> Task(List(b), e) # one column, every row
|
|
|
55
55
|
|
|
56
56
|
`fetch_exists` stops at the first row Postgres finds rather than counting
|
|
57
57
|
every one of them. `exists` is the `Expr(Bool)` a `WHERE` takes, which is
|
|
58
|
-
where the keyword appears in SQL. `fetch_values` reads one column;
|
|
59
|
-
|
|
60
|
-
projection bug with no field names to catch it.
|
|
58
|
+
where the keyword appears in SQL. `fetch_values` reads one column; for more
|
|
59
|
+
than one, `select |> field` names the shape they land in.
|
|
61
60
|
|
|
62
61
|
For raw SQL, skip the builders: `fetch_one_raw` / `fetch_many_raw` /
|
|
63
62
|
`execute_raw` take a `(String, List(Value))` pair. Their result type is
|
|
64
63
|
unconstrained, which is honest — nothing about a hand-written string says
|
|
65
64
|
what it returns.
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
A projection's rows come back as arrays, and each column goes through its
|
|
67
|
+
field type's `Decodable` in the position `field` put it. The `*_raw` runners
|
|
68
|
+
have no projection, so they decode by column name into the result type
|
|
69
|
+
instead.
|
|
71
70
|
|
|
72
71
|
`SqlError` variants:
|
|
73
72
|
- `NotFound` — `fetch_one` with zero rows
|
|
@@ -174,10 +173,8 @@ describe MyApp do
|
|
|
174
173
|
include Jade::Tasks::RSpec
|
|
175
174
|
|
|
176
175
|
it 'queries patients' do
|
|
177
|
-
all_calls_to(JadeSql::Runtime.
|
|
178
|
-
t.ok([
|
|
179
|
-
{ "id" => 1, "name" => "Paul", "mrn" => "MRN-001" }
|
|
180
|
-
])
|
|
176
|
+
all_calls_to(JadeSql::Runtime.port_execute_rows) do |t, _sql, _params|
|
|
177
|
+
t.ok([[1, "Paul", "MRN-001"]])
|
|
181
178
|
end
|
|
182
179
|
|
|
183
180
|
expect(MyApp.list.run).to be_ok
|
|
@@ -185,7 +182,9 @@ describe MyApp do
|
|
|
185
182
|
end
|
|
186
183
|
```
|
|
187
184
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
Everything dispatches through four ports. `port_execute_rows` runs the
|
|
186
|
+
builders' reads and RETURNING writes and answers with one array per row, in
|
|
187
|
+
`field` order. `port_execute_count` runs `execute`, and `port_execute_one` /
|
|
188
|
+
`port_execute_many` run the `*_raw` reads, answering with hashes keyed by
|
|
189
|
+
column name. Stub them with
|
|
191
190
|
`all_calls_to(JadeSql::Runtime.port_execute_*) { |t, sql, params| ... }`.
|
|
@@ -503,7 +503,7 @@ module JadeSql
|
|
|
503
503
|
].sort
|
|
504
504
|
sql_import = "import Sql exposing(#{(types + fns).join(', ')})"
|
|
505
505
|
enum_imports = enum_imports_for(tables, module_name)
|
|
506
|
-
query_import = ["import Sql.Query exposing(Select,
|
|
506
|
+
query_import = ["import Sql.Query exposing(Select, field, select)"]
|
|
507
507
|
# A join predicate compares two columns, which is `Sql.Expr`'s side of
|
|
508
508
|
# the operator split rather than `Sql`'s value-taking one.
|
|
509
509
|
expr_import = joined.any? ? ["import Sql.Expr as Expr"] : []
|
|
@@ -793,17 +793,14 @@ module JadeSql
|
|
|
793
793
|
JADE
|
|
794
794
|
end
|
|
795
795
|
|
|
796
|
-
#
|
|
797
|
-
# field name, so a reserved-word column like `type` round-trips through
|
|
798
|
-
# decode: `SELECT alias.type AS type_`. Emitted only for tables that have
|
|
799
|
-
# a renamed column. Composes in a bind-chain:
|
|
796
|
+
# Every column of the table into its Row struct. Composes in a bind-chain:
|
|
800
797
|
# c <- from(t)
|
|
801
798
|
# t_row(c) |> where(...)
|
|
802
799
|
def emit_row_projector(t)
|
|
803
800
|
klass = camel(t.name)
|
|
804
801
|
holes = t.columns.map { "_" }.join(", ")
|
|
805
802
|
projections = t.columns
|
|
806
|
-
.map { |c| " |>
|
|
803
|
+
.map { |c| " |> field(c.#{field_name(c.name)})" }
|
|
807
804
|
.join("\n")
|
|
808
805
|
|
|
809
806
|
<<~JADE.strip
|
|
@@ -851,6 +848,7 @@ module JadeSql
|
|
|
851
848
|
|
|
852
849
|
import Decode exposing (Decodable, Decoder, Value)
|
|
853
850
|
import Encode exposing (Encodable)
|
|
851
|
+
import Sql.Expr exposing (Finite)
|
|
854
852
|
|
|
855
853
|
|
|
856
854
|
type #{type_name}
|
|
@@ -895,6 +893,16 @@ module JadeSql
|
|
|
895
893
|
%( else Decode.fail("not a #{enum.name}: " ++ s)),
|
|
896
894
|
' end',
|
|
897
895
|
'end',
|
|
896
|
+
'',
|
|
897
|
+
'',
|
|
898
|
+
"implements Finite(#{type_name}) with",
|
|
899
|
+
" variants: #{snake}_variants",
|
|
900
|
+
'end',
|
|
901
|
+
'',
|
|
902
|
+
'',
|
|
903
|
+
"def #{snake}_variants -> List(#{type_name})",
|
|
904
|
+
" [#{variants_of(enum).join(', ')}]",
|
|
905
|
+
'end',
|
|
898
906
|
].join("\n")
|
|
899
907
|
end
|
|
900
908
|
|
data/lib/jade-sql/runtime.rb
CHANGED
|
@@ -36,6 +36,14 @@ module JadeSql
|
|
|
36
36
|
t.err(translate(e))
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
task :port_execute_rows do |t, sql, params|
|
|
40
|
+
conn = ::ActiveRecord::Base.connection
|
|
41
|
+
rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).rows
|
|
42
|
+
t.ok(rows.map { |row| row.map { coerce_value(it) } })
|
|
43
|
+
rescue ::ActiveRecord::StatementInvalid => e
|
|
44
|
+
t.err(translate(e))
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
# Transaction control on the shared connection. The execute/fetch ports
|
|
40
48
|
# above use the same `ActiveRecord::Base.connection`, so anything they
|
|
41
49
|
# run between begin and commit/rollback is part of this transaction.
|
data/lib/jade-sql/sql/expr.jd
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
module Sql.Expr exposing (
|
|
2
|
+
Case,
|
|
3
|
+
Finite,
|
|
4
|
+
Matching,
|
|
5
|
+
Searched,
|
|
6
|
+
case_of,
|
|
7
|
+
case_when,
|
|
8
|
+
coalesce,
|
|
9
|
+
concat,
|
|
10
|
+
div,
|
|
2
11
|
eq,
|
|
12
|
+
filtered_to,
|
|
13
|
+
greatest,
|
|
3
14
|
gt,
|
|
4
15
|
gte,
|
|
5
16
|
ilike,
|
|
17
|
+
least,
|
|
6
18
|
like,
|
|
7
19
|
lt,
|
|
8
20
|
lte,
|
|
21
|
+
minus,
|
|
9
22
|
neq,
|
|
23
|
+
otherwise,
|
|
24
|
+
per_variant,
|
|
25
|
+
plus,
|
|
26
|
+
plus_days,
|
|
27
|
+
times,
|
|
28
|
+
when,
|
|
29
|
+
when_eq,
|
|
10
30
|
)
|
|
11
31
|
|
|
12
32
|
import Sql exposing (Expr(..))
|
|
33
|
+
import Encode exposing (Encodable, encode)
|
|
34
|
+
import Decode exposing (Value)
|
|
35
|
+
import Calendar exposing (Date)
|
|
13
36
|
|
|
14
37
|
|
|
15
38
|
# The same comparisons `Sql` exposes, against another expression rather than a
|
|
@@ -63,3 +86,198 @@ end
|
|
|
63
86
|
def binary(left: Expr(a), op: String, right: Expr(b)) -> Expr(Bool)
|
|
64
87
|
Expr(left.sql ++ op ++ right.sql, left.params ++ right.params)
|
|
65
88
|
end
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# A type whose every value is known: a database enum, whose instance the schema
|
|
92
|
+
# generator writes next to its codec, and `Bool`.
|
|
93
|
+
# A type with finitely many values, all of them listed. The schema generator
|
|
94
|
+
# writes an instance beside each database enum's codec, so the list is
|
|
95
|
+
# regenerated with the type. Writing one by hand is the one way to make
|
|
96
|
+
# `per_variant` render a CASE that misses a value.
|
|
97
|
+
interface Finite(a) with
|
|
98
|
+
variants: List(a)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
implements Finite(Bool) with
|
|
103
|
+
variants: bool_variants
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def bool_variants -> List(Bool)
|
|
108
|
+
[True, False]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# A `CASE` with one arm per value of `e`, each arm what `branch` makes of it.
|
|
113
|
+
# `branch` is an ordinary function, so the `case` inside it is checked for
|
|
114
|
+
# every value, and the SQL needs no `ELSE`:
|
|
115
|
+
#
|
|
116
|
+
# t.kind |> Expr.per_variant((k) -> {
|
|
117
|
+
# case k
|
|
118
|
+
# in Income then tl.amount_cents
|
|
119
|
+
# in Expense then neg(tl.amount_cents)
|
|
120
|
+
# end
|
|
121
|
+
# })
|
|
122
|
+
# # (CASE t.kind WHEN ? THEN tl.amount_cents WHEN ? THEN -(tl.amount_cents) END)
|
|
123
|
+
#
|
|
124
|
+
# `per_variant` is finished the moment you call it — the arms are the
|
|
125
|
+
# type's, so there is no `otherwise` to mark the end.
|
|
126
|
+
#
|
|
127
|
+
# `branch` runs once per value while the query is built, not once per row. A
|
|
128
|
+
# NULL scrutinee matches no arm and the CASE is NULL, so a nullable column
|
|
129
|
+
# belongs in `case_when` with `is_null` rather than here.
|
|
130
|
+
def per_variant(e: Expr(a), branch: a -> Expr(b)) -> Expr(b)
|
|
131
|
+
arms = variants |> List.map((v) -> { (encode(v), branch(v)) })
|
|
132
|
+
whens = arms
|
|
133
|
+
|> List.map((arm) -> { " WHEN ? THEN " ++ Tuple.second(arm).sql })
|
|
134
|
+
|> String.concat
|
|
135
|
+
params = arms
|
|
136
|
+
|> List.and_then((arm) -> { [Tuple.first(arm)] ++ Tuple.second(arm).params })
|
|
137
|
+
|
|
138
|
+
Expr("(CASE " ++ e.sql ++ whens ++ " END)", e.params ++ params)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Arithmetic on integers. Postgres divides integers by truncating toward
|
|
143
|
+
# zero, and raises on a zero divisor.
|
|
144
|
+
def plus(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
|
|
145
|
+
operator(left, " + ", right)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def minus(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
|
|
150
|
+
operator(left, " - ", right)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def times(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
|
|
155
|
+
operator(left, " * ", right)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def div(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
|
|
160
|
+
operator(left, " / ", right)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def concat(left: Expr(String), right: Expr(String)) -> Expr(String)
|
|
165
|
+
operator(left, " || ", right)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Typed on both sides, since `date + integer` is one of several `+`s and a
|
|
170
|
+
# bound value names neither.
|
|
171
|
+
def plus_days(d: Expr(Date), days: Expr(Int)) -> Expr(Date)
|
|
172
|
+
Expr(
|
|
173
|
+
"((" ++ d.sql ++ ")::date + (" ++ days.sql ++ ")::integer)",
|
|
174
|
+
d.params ++ days.params,
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def operator(left: Expr(a), op: String, right: Expr(b)) -> Expr(c)
|
|
180
|
+
Expr("(" ++ left.sql ++ op ++ right.sql ++ ")", left.params ++ right.params)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# `COALESCE` against an expression: another column, a subquery. `Sql.coalesce`
|
|
185
|
+
# takes a value you hold.
|
|
186
|
+
def coalesce(e: Expr(Maybe(a)), fallback: Expr(a)) -> Expr(a)
|
|
187
|
+
Expr("COALESCE(" ++ e.sql ++ ", " ++ fallback.sql ++ ")", e.params ++ fallback.params)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def greatest(left: Expr(a), right: Expr(a)) -> Expr(a)
|
|
192
|
+
Expr("GREATEST(" ++ left.sql ++ ", " ++ right.sql ++ ")", left.params ++ right.params)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def least(left: Expr(a), right: Expr(a)) -> Expr(a)
|
|
197
|
+
Expr("LEAST(" ++ left.sql ++ ", " ++ right.sql ++ ")", left.params ++ right.params)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# An aggregate over only the rows `condition` keeps:
|
|
202
|
+
#
|
|
203
|
+
# sum(tl.amount_cents) |> Expr.filtered_to(eq(t.kind, Income))
|
|
204
|
+
# # SUM(tl.amount_cents) FILTER (WHERE t.kind = ?)
|
|
205
|
+
def filtered_to(aggregate: Expr(a), condition: Expr(Bool)) -> Expr(a)
|
|
206
|
+
Expr(
|
|
207
|
+
aggregate.sql ++ " FILTER (WHERE " ++ condition.sql ++ ")",
|
|
208
|
+
aggregate.params ++ condition.params,
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# A `CASE` you close yourself, in the two shapes Postgres writes. `t` says
|
|
214
|
+
# which shape — `Searched` for `case_when`, `Matching(a)` for a `case_of` over
|
|
215
|
+
# an `Expr(a)` — so an arm can only be added by the builder it belongs to, and
|
|
216
|
+
# `b` is what the arms produce. A `Case` is not an expression
|
|
217
|
+
# until `otherwise` gives it an `ELSE`, and each builder takes its first arm,
|
|
218
|
+
# so a `CASE` with no arms cannot be written:
|
|
219
|
+
#
|
|
220
|
+
# case_when(p.age |> Expr.lt(val(18)), val("minor"))
|
|
221
|
+
# |> Expr.when(p.age |> Expr.gte(val(65)), val("senior"))
|
|
222
|
+
# |> Expr.otherwise(val("adult"))
|
|
223
|
+
# # (CASE WHEN p.age < ? THEN ? WHEN p.age >= ? THEN ? ELSE ? END)
|
|
224
|
+
#
|
|
225
|
+
# case_of(p.tier, val(1), val("gold"))
|
|
226
|
+
# |> Expr.when_eq(val(2), val("silver"))
|
|
227
|
+
# |> Expr.otherwise(val("basic"))
|
|
228
|
+
# # (CASE p.tier WHEN ? THEN ? WHEN ? THEN ? ELSE ? END)
|
|
229
|
+
#
|
|
230
|
+
# Over a type whose values are all known, `per_variant` covers them without
|
|
231
|
+
# an `ELSE`, and the compiler checks the arms. Reach for `case_of` when the
|
|
232
|
+
# values are not, and read the arms carefully: both sides of `when_eq` are
|
|
233
|
+
# expressions of the same shape, so a mapping written the wrong way round is
|
|
234
|
+
# still a mapping.
|
|
235
|
+
type Searched = SearchedOnly
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
type Matching(a) = MatchingOnly
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
struct Case(t, b) = {
|
|
242
|
+
sql: String,
|
|
243
|
+
params: List(Value)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def case_when(condition: Expr(Bool), result: Expr(b)) -> Case(Searched, b)
|
|
248
|
+
Case(
|
|
249
|
+
"CASE WHEN " ++ condition.sql ++ " THEN " ++ result.sql,
|
|
250
|
+
condition.params ++ result.params,
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def when(c: Case(Searched, b), condition: Expr(Bool), result: Expr(b)) -> Case(Searched, b)
|
|
256
|
+
with_arm(c, condition, result)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def case_of(e: Expr(a), value: Expr(a), result: Expr(b)) -> Case(Matching(a), b)
|
|
261
|
+
Case(
|
|
262
|
+
"CASE " ++ e.sql ++ " WHEN " ++ value.sql ++ " THEN " ++ result.sql,
|
|
263
|
+
e.params ++ value.params ++ result.params,
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def when_eq(c: Case(Matching(a), b), value: Expr(a), result: Expr(b)) -> Case(Matching(a), b)
|
|
269
|
+
with_arm(c, value, result)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def with_arm(c: Case(t, b), matched: Expr(a), result: Expr(b)) -> Case(t, b)
|
|
274
|
+
Case(
|
|
275
|
+
c.sql ++ " WHEN " ++ matched.sql ++ " THEN " ++ result.sql,
|
|
276
|
+
c.params ++ matched.params ++ result.params,
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def otherwise(c: Case(t, b), fallback: Expr(b)) -> Expr(b)
|
|
282
|
+
Expr("(" ++ c.sql ++ " ELSE " ++ fallback.sql ++ " END)", c.params ++ fallback.params)
|
|
283
|
+
end
|
data/lib/jade-sql/sql/json.jd
CHANGED
|
@@ -128,12 +128,11 @@ def text(d: Doc(a)) -> String
|
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# module, and the signature ties the result's tag back to the projection's.
|
|
131
|
+
# `unsafe_cast` erases Json(a) to the String the column actually holds — it
|
|
132
|
+
# stays inside this module, and the signature ties the result's tag back to
|
|
133
|
+
# the projection's.
|
|
135
134
|
def select(e: Expr(Json(a))) -> Query(Selector(Doc(a)))
|
|
136
|
-
Query.select(Doc(_)) |> Query.
|
|
135
|
+
Query.select(Doc(_)) |> Query.field(unsafe_cast(e))
|
|
137
136
|
end
|
|
138
137
|
|
|
139
138
|
|
data/lib/jade-sql/sql/query.jd
CHANGED
|
@@ -2,7 +2,6 @@ module Sql.Query exposing (
|
|
|
2
2
|
Query,
|
|
3
3
|
Select,
|
|
4
4
|
field,
|
|
5
|
-
field_as,
|
|
6
5
|
distinct,
|
|
7
6
|
exists,
|
|
8
7
|
from,
|
|
@@ -37,7 +36,7 @@ import Sql exposing (
|
|
|
37
36
|
ToSql,
|
|
38
37
|
from_sql_error,
|
|
39
38
|
)
|
|
40
|
-
import Decode exposing (Value)
|
|
39
|
+
import Decode exposing (Decodable, Decoder, Value)
|
|
41
40
|
|
|
42
41
|
|
|
43
42
|
type JoinKind
|
|
@@ -257,28 +256,25 @@ end
|
|
|
257
256
|
|
|
258
257
|
|
|
259
258
|
def select(make: a -> b) -> Query(Selector(a -> b))
|
|
260
|
-
Query([], [], [], [], [], [], Nothing, Nothing, False, Selector([], []), Nothing)
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def field(qs: Query(Selector(a -> b)), e: Expr(a)) -> Select(b)
|
|
265
259
|
Query(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
Selector(
|
|
276
|
-
|
|
260
|
+
[],
|
|
261
|
+
[],
|
|
262
|
+
[],
|
|
263
|
+
[],
|
|
264
|
+
[],
|
|
265
|
+
[],
|
|
266
|
+
Nothing,
|
|
267
|
+
Nothing,
|
|
268
|
+
False,
|
|
269
|
+
Selector([], [], Decode.succeed(make)),
|
|
270
|
+
Nothing,
|
|
277
271
|
)
|
|
278
272
|
end
|
|
279
273
|
|
|
280
274
|
|
|
281
|
-
def
|
|
275
|
+
def field(qs: Query(Selector(a -> b)), e: Expr(a)) -> Select(b)
|
|
276
|
+
position = List.length(qs.result.columns_sql)
|
|
277
|
+
|
|
282
278
|
Query(
|
|
283
279
|
qs.tables,
|
|
284
280
|
qs.joins,
|
|
@@ -290,8 +286,9 @@ def field_as(qs: Query(Selector(a -> b)), e: Expr(a), name: String) -> Select(b)
|
|
|
290
286
|
qs.offset_,
|
|
291
287
|
qs.distinct_,
|
|
292
288
|
Selector(
|
|
293
|
-
qs.result.columns_sql ++ [e.sql
|
|
289
|
+
qs.result.columns_sql ++ [e.sql],
|
|
294
290
|
qs.result.params ++ e.params,
|
|
291
|
+
qs.result.decoder |> Decode.and_map(Decode.index(position, Decode.decoder)),
|
|
295
292
|
),
|
|
296
293
|
qs.result_alias,
|
|
297
294
|
)
|
|
@@ -446,15 +443,17 @@ end
|
|
|
446
443
|
# `NotFound` on none and `TooManyRows` on more than one, so "exactly one" is
|
|
447
444
|
# checked rather than assumed.
|
|
448
445
|
def fetch_one(q: Select(a)) -> Task(a, e)
|
|
449
|
-
to_sql(q)
|
|
446
|
+
Sql.fetch_decoded(to_sql(q), q.result.decoder)
|
|
447
|
+
|> Task.and_then(Sql.exactly_one)
|
|
448
|
+
|> Task.map_error(from_sql_error)
|
|
450
449
|
end
|
|
451
450
|
|
|
452
451
|
|
|
453
452
|
# `fetch_one` where no row is an answer rather than an error. More than one
|
|
454
453
|
# is still `TooManyRows`: nothing is dropped to make the type fit.
|
|
455
454
|
def fetch_at_most_one(q: Select(a)) -> Task(Maybe(a), e)
|
|
456
|
-
to_sql(q)
|
|
457
|
-
|> Sql.
|
|
455
|
+
Sql.fetch_decoded(to_sql(q), q.result.decoder)
|
|
456
|
+
|> Task.and_then(Sql.exactly_one)
|
|
458
457
|
|> Task.map(Just)
|
|
459
458
|
|> Task.on_error(nothing_if_missing)
|
|
460
459
|
|> Task.map_error(from_sql_error)
|
|
@@ -474,74 +473,42 @@ end
|
|
|
474
473
|
# Runs the query and decodes every row it selected. No rows is an empty list,
|
|
475
474
|
# not an error.
|
|
476
475
|
def fetch_many(q: Select(a)) -> Task(List(a), e)
|
|
477
|
-
to_sql(q)
|
|
476
|
+
Sql.fetch_decoded(to_sql(q), q.result.decoder) |> Task.map_error(from_sql_error)
|
|
478
477
|
end
|
|
479
478
|
|
|
480
479
|
|
|
481
|
-
# A read with nothing to decode renders its own select list over the query's
|
|
482
|
-
# clauses, so it needs no projection and carries no `Selectable`. The port
|
|
483
|
-
# hands back a record either way, which is what these one-field shapes are
|
|
484
|
-
# for.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
struct CountRow = { tally: Int }
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
struct PresentRow = { present: Bool }
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
struct ValueRow(a) = { value: a }
|
|
494
|
-
|
|
495
|
-
|
|
496
480
|
def fetch_count(q: Query(a)) -> Task(Int, e)
|
|
497
|
-
render(q, ["COUNT(*)
|
|
498
|
-
|> Sql.
|
|
499
|
-
|> Task.map(count_of)
|
|
481
|
+
Sql.fetch_decoded(render(q, ["COUNT(*)"], []), Decode.index(0, Decode.int))
|
|
482
|
+
|> Task.and_then(Sql.exactly_one)
|
|
500
483
|
|> Task.map_error(from_sql_error)
|
|
501
484
|
end
|
|
502
485
|
|
|
503
486
|
|
|
504
|
-
def count_of(r: CountRow) -> Int
|
|
505
|
-
r.tally
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
|
|
509
487
|
# `SELECT EXISTS (...)`, which stops at the first row Postgres finds rather
|
|
510
488
|
# than counting every one of them. `exists` is the `Expr(Bool)` for a `WHERE`,
|
|
511
489
|
# where the keyword actually appears.
|
|
512
490
|
def fetch_exists(q: Query(a)) -> Task(Bool, e)
|
|
513
491
|
present = exists(q)
|
|
514
492
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
493
|
+
Sql.fetch_decoded(
|
|
494
|
+
("SELECT " ++ present.sql, present.params),
|
|
495
|
+
Decode.index(0, Decode.bool),
|
|
496
|
+
)
|
|
497
|
+
|> Task.and_then(Sql.exactly_one)
|
|
518
498
|
|> Task.map_error(from_sql_error)
|
|
519
499
|
end
|
|
520
500
|
|
|
521
501
|
|
|
522
|
-
def present_of(r: PresentRow) -> Bool
|
|
523
|
-
r.present
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
|
|
527
502
|
# One column out of every row, for the reads whose answer is a list of values
|
|
528
503
|
# rather than a list of rows:
|
|
529
504
|
#
|
|
530
505
|
# from(patients) |> where(p.archived |> eq(False)) |> fetch_values(p.id)
|
|
531
506
|
#
|
|
532
|
-
# Worth it only for a single column
|
|
533
|
-
#
|
|
534
|
-
# names to catch it — that is what `select |> field` is for.
|
|
507
|
+
# Worth it only for a single column; more than one is what `select |> field`
|
|
508
|
+
# is for.
|
|
535
509
|
def fetch_values(q: Query(c), e: Expr(b)) -> Task(List(b), err)
|
|
536
|
-
render(q, [e.sql
|
|
537
|
-
|> Sql.fetch_many_raw
|
|
538
|
-
|> Task.map((rows) -> { List.map(rows, value_of) })
|
|
510
|
+
Sql.fetch_decoded(render(q, [e.sql], e.params), Decode.index(0, Decode.decoder))
|
|
539
511
|
|> Task.map_error(from_sql_error)
|
|
540
512
|
end
|
|
541
513
|
|
|
542
514
|
|
|
543
|
-
def value_of(r: ValueRow(a)) -> a
|
|
544
|
-
r.value
|
|
545
|
-
end
|
|
546
|
-
|
|
547
|
-
|
data/lib/jade-sql/sql/write.jd
CHANGED
|
@@ -12,7 +12,7 @@ module Sql.Write exposing (
|
|
|
12
12
|
insert,
|
|
13
13
|
insert_all,
|
|
14
14
|
Stamped,
|
|
15
|
-
|
|
15
|
+
returning,
|
|
16
16
|
timestamped,
|
|
17
17
|
to_sql,
|
|
18
18
|
update,
|
|
@@ -37,7 +37,7 @@ import Sql exposing (
|
|
|
37
37
|
to_assigns,
|
|
38
38
|
)
|
|
39
39
|
import Sql.Query exposing (Query)
|
|
40
|
-
import Decode exposing (Value)
|
|
40
|
+
import Decode exposing (Decoder, Value)
|
|
41
41
|
import Encode
|
|
42
42
|
|
|
43
43
|
|
|
@@ -87,6 +87,11 @@ def do_update(build: s -> List(Assignment)) -> Action(s)
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def no_returning -> Selector(Int)
|
|
91
|
+
Selector([], [], Decode.fail("a write without RETURNING has no rows"))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
90
95
|
def insert(value: a, t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
|
|
91
96
|
Write(
|
|
92
97
|
InsertK,
|
|
@@ -95,7 +100,7 @@ def insert(value: a, t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
|
|
|
95
100
|
t.set_cols,
|
|
96
101
|
[to_assigns(value)],
|
|
97
102
|
[],
|
|
98
|
-
|
|
103
|
+
no_returning,
|
|
99
104
|
Nothing,
|
|
100
105
|
Nothing,
|
|
101
106
|
)
|
|
@@ -110,7 +115,7 @@ def insert_all(values: List(a), t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
|
|
|
110
115
|
t.set_cols,
|
|
111
116
|
List.map(values, (v) -> { to_assigns(v) }),
|
|
112
117
|
[],
|
|
113
|
-
|
|
118
|
+
no_returning,
|
|
114
119
|
Nothing,
|
|
115
120
|
Nothing,
|
|
116
121
|
)
|
|
@@ -125,7 +130,7 @@ def update(value: a, t: Table(c, m, k, o, r, s), key: k) -> Write(Int, c, s)
|
|
|
125
130
|
t.set_cols,
|
|
126
131
|
[without_created_at(without_key(t.pk, to_assigns(value)))],
|
|
127
132
|
[key_predicate(t.pk, key)],
|
|
128
|
-
|
|
133
|
+
no_returning,
|
|
129
134
|
Nothing,
|
|
130
135
|
Nothing,
|
|
131
136
|
)
|
|
@@ -140,7 +145,7 @@ def delete(t: Table(c, m, k, o, r, s), key: k) -> Write(Int, c, s)
|
|
|
140
145
|
t.set_cols,
|
|
141
146
|
[],
|
|
142
147
|
[key_predicate(t.pk, key)],
|
|
143
|
-
|
|
148
|
+
no_returning,
|
|
144
149
|
Nothing,
|
|
145
150
|
Nothing,
|
|
146
151
|
)
|
|
@@ -177,7 +182,7 @@ def update_all(
|
|
|
177
182
|
t.set_cols,
|
|
178
183
|
[build(cols_, t.set_cols)],
|
|
179
184
|
[pred],
|
|
180
|
-
|
|
185
|
+
no_returning,
|
|
181
186
|
Nothing,
|
|
182
187
|
Nothing,
|
|
183
188
|
)
|
|
@@ -194,7 +199,7 @@ def update_many(rows_: List((k, a)), t: Table(c, m, k, o, r, s)) -> Write(Int, c
|
|
|
194
199
|
t.set_cols,
|
|
195
200
|
[source_assigns(t.pk, rows)],
|
|
196
201
|
[pk_join(t.alias_, t.pk.columns)],
|
|
197
|
-
|
|
202
|
+
no_returning,
|
|
198
203
|
Just(source_of(t.name, rows)),
|
|
199
204
|
Nothing,
|
|
200
205
|
)
|
|
@@ -280,7 +285,7 @@ def delete_all(t: Table(c, m, k, o, r, s), pred_fn: c -> Expr(Bool)) -> Write(In
|
|
|
280
285
|
t.set_cols,
|
|
281
286
|
[],
|
|
282
287
|
[pred],
|
|
283
|
-
|
|
288
|
+
no_returning,
|
|
284
289
|
Nothing,
|
|
285
290
|
Nothing,
|
|
286
291
|
)
|
|
@@ -303,7 +308,7 @@ def on_conflict(m: Write(ret, c, s), u: Unique(c, uk), action: Action(s)) -> Wri
|
|
|
303
308
|
end
|
|
304
309
|
|
|
305
310
|
|
|
306
|
-
def
|
|
311
|
+
def returning(m: Write(a, c, s), build: c -> Query(Selector(b))) -> Write(b, c, s)
|
|
307
312
|
Write(
|
|
308
313
|
m.kind,
|
|
309
314
|
m.table,
|
|
@@ -607,12 +612,15 @@ end
|
|
|
607
612
|
# insert, the new state of an update. A write with no `returning` selects
|
|
608
613
|
# nothing, so this errors with `NotFound`.
|
|
609
614
|
def fetch_one(m: Write(ret, c, s)) -> Task(ret, e)
|
|
610
|
-
to_sql(m)
|
|
615
|
+
Sql.fetch_decoded(to_sql(m), m.returning_selector.decoder)
|
|
616
|
+
|> Task.and_then(Sql.exactly_one)
|
|
617
|
+
|> Task.map_error(from_sql_error)
|
|
611
618
|
end
|
|
612
619
|
|
|
613
620
|
|
|
614
621
|
# The same for a write that returns many rows, as `insert_all` and
|
|
615
622
|
# `update_all` do.
|
|
616
623
|
def fetch_many(m: Write(ret, c, s)) -> Task(List(ret), e)
|
|
617
|
-
to_sql(m)
|
|
624
|
+
Sql.fetch_decoded(to_sql(m), m.returning_selector.decoder)
|
|
625
|
+
|> Task.map_error(from_sql_error)
|
|
618
626
|
end
|
data/lib/jade-sql/sql.jd
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Sql exposing (
|
|
2
2
|
Assignable,
|
|
3
3
|
Assignment(..),
|
|
4
|
+
DateUnit(..),
|
|
4
5
|
Col(..),
|
|
5
6
|
Expr(..),
|
|
6
7
|
NoJoins,
|
|
@@ -28,16 +29,20 @@ module Sql exposing (
|
|
|
28
29
|
array_overlaps,
|
|
29
30
|
array_remove,
|
|
30
31
|
assign,
|
|
32
|
+
avg,
|
|
31
33
|
coalesce,
|
|
32
34
|
column,
|
|
33
35
|
columns,
|
|
34
36
|
count,
|
|
35
37
|
count_all,
|
|
38
|
+
count_distinct,
|
|
36
39
|
db_now,
|
|
37
40
|
eq,
|
|
41
|
+
exactly_one,
|
|
38
42
|
excluded,
|
|
39
43
|
execute,
|
|
40
44
|
execute_raw,
|
|
45
|
+
fetch_decoded,
|
|
41
46
|
fetch_many_raw,
|
|
42
47
|
fetch_one_raw,
|
|
43
48
|
gt,
|
|
@@ -45,12 +50,16 @@ module Sql exposing (
|
|
|
45
50
|
ilike,
|
|
46
51
|
is_not_null,
|
|
47
52
|
is_null,
|
|
53
|
+
json_text,
|
|
48
54
|
jsonb_contains,
|
|
49
55
|
jsonb_path_exists,
|
|
50
56
|
left_columns,
|
|
51
57
|
like,
|
|
58
|
+
lower,
|
|
52
59
|
lt,
|
|
53
60
|
lte,
|
|
61
|
+
max,
|
|
62
|
+
min,
|
|
54
63
|
neg,
|
|
55
64
|
neq,
|
|
56
65
|
no_joins,
|
|
@@ -65,16 +74,21 @@ module Sql exposing (
|
|
|
65
74
|
table,
|
|
66
75
|
to_assigns,
|
|
67
76
|
transaction,
|
|
77
|
+
trunc_date,
|
|
68
78
|
unique,
|
|
69
79
|
unkeyed,
|
|
70
80
|
unsafe_cast,
|
|
81
|
+
upper,
|
|
71
82
|
val,
|
|
72
83
|
within,
|
|
73
84
|
)
|
|
74
85
|
|
|
75
86
|
import Encode exposing (Encodable, encode)
|
|
76
87
|
import Decode exposing (Decodable, Decoder, Value)
|
|
88
|
+
import Calendar exposing (Date)
|
|
77
89
|
import Clock exposing (Instant)
|
|
90
|
+
import Decimal exposing (Decimal)
|
|
91
|
+
import Show exposing (show)
|
|
78
92
|
|
|
79
93
|
|
|
80
94
|
struct Expr(a) = {
|
|
@@ -85,7 +99,8 @@ struct Expr(a) = {
|
|
|
85
99
|
|
|
86
100
|
struct Selector(a) = {
|
|
87
101
|
columns_sql: List(String),
|
|
88
|
-
params: List(Value)
|
|
102
|
+
params: List(Value),
|
|
103
|
+
decoder: Decoder(a)
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
|
|
@@ -289,7 +304,7 @@ end
|
|
|
289
304
|
# so this is for the positions that cannot: a constant field in a projection
|
|
290
305
|
# or a JSON document.
|
|
291
306
|
#
|
|
292
|
-
# select(Row(_, _)) |> field(c.id) |>
|
|
307
|
+
# select(Row(_, _)) |> field(c.id) |> field(val("patient"))
|
|
293
308
|
# Json.object(Row(_)) |> Json.prop("kind", val("patient"))
|
|
294
309
|
#
|
|
295
310
|
# Every library that has this calls it `val`, and none of them has a word for
|
|
@@ -437,6 +452,74 @@ def neg(e: Expr(Int)) -> Expr(Int)
|
|
|
437
452
|
end
|
|
438
453
|
|
|
439
454
|
|
|
455
|
+
# Aggregates over no rows are NULL, so all but the counts come back in `Maybe`.
|
|
456
|
+
def max(e: Expr(a)) -> Expr(Maybe(a))
|
|
457
|
+
Expr("MAX(" ++ e.sql ++ ")", e.params)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def min(e: Expr(a)) -> Expr(Maybe(a))
|
|
462
|
+
Expr("MIN(" ++ e.sql ++ ")", e.params)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# Postgres averages integers as `numeric`, which is exact.
|
|
467
|
+
def avg(e: Expr(Int)) -> Expr(Maybe(Decimal))
|
|
468
|
+
Expr("AVG(" ++ e.sql ++ ")", e.params)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def count_distinct(e: Expr(a)) -> Expr(Int)
|
|
473
|
+
Expr("COUNT(DISTINCT " ++ e.sql ++ ")", e.params)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def lower(e: Expr(String)) -> Expr(String)
|
|
478
|
+
Expr("LOWER(" ++ e.sql ++ ")", e.params)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def upper(e: Expr(String)) -> Expr(String)
|
|
483
|
+
Expr("UPPER(" ++ e.sql ++ ")", e.params)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
type DateUnit
|
|
488
|
+
= Year
|
|
489
|
+
| Quarter
|
|
490
|
+
| Month
|
|
491
|
+
| Week
|
|
492
|
+
| Day
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# The first day of the unit `d` falls in. `date_trunc` has no date form: the
|
|
496
|
+
# date goes in as a timestamp, which a bound one would not name on its own, and
|
|
497
|
+
# the result comes back to a date.
|
|
498
|
+
def trunc_date(unit: DateUnit, d: Expr(Date)) -> Expr(Date)
|
|
499
|
+
Expr(
|
|
500
|
+
"date_trunc('" ++ unit_name(unit) ++ "', (" ++ d.sql ++ ")::timestamp)::date",
|
|
501
|
+
d.params,
|
|
502
|
+
)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def unit_name(unit: DateUnit) -> String
|
|
507
|
+
case unit
|
|
508
|
+
in Year then "year"
|
|
509
|
+
in Quarter then "quarter"
|
|
510
|
+
in Month then "month"
|
|
511
|
+
in Week then "week"
|
|
512
|
+
in Day then "day"
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# A key's text out of a JSON object, `NULL` where the key is absent.
|
|
518
|
+
def json_text(col: Expr(Value), key: String) -> Expr(Maybe(String))
|
|
519
|
+
Expr("(" ++ col.sql ++ " ->> ?)", col.params ++ [encode(key)])
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
|
|
440
523
|
# PG array predicates.
|
|
441
524
|
# All bind the array as a single param (`$1`) instead of expanding to
|
|
442
525
|
# `ARRAY[$1,...,$N]`. The pg driver maps Ruby Array → PG array; PG
|
|
@@ -701,6 +784,7 @@ uses JadeSql::Runtime with
|
|
|
701
784
|
port_execute_count : String, List(Value) -> Task(Int, SqlError),
|
|
702
785
|
port_execute_one : String, List(Value) -> Task(a, SqlError),
|
|
703
786
|
port_execute_many : String, List(Value) -> Task(List(a), SqlError),
|
|
787
|
+
port_execute_rows : String, List(Value) -> Task(Value, SqlError),
|
|
704
788
|
port_begin : Task(Bool, SqlError),
|
|
705
789
|
port_commit : Task(Bool, SqlError),
|
|
706
790
|
port_rollback : Task(Bool, SqlError)
|
|
@@ -725,6 +809,32 @@ def fetch_many_raw(p: (String, List(Value))) -> Task(List(a), SqlError)
|
|
|
725
809
|
end
|
|
726
810
|
|
|
727
811
|
|
|
812
|
+
# The rows as arrays, each through `decoder`: a projection decodes by
|
|
813
|
+
# position, so a column's name never has to match the field it lands in.
|
|
814
|
+
def fetch_decoded(p: (String, List(Value)), decoder: Decoder(a)) -> Task(List(a), SqlError)
|
|
815
|
+
(sql, params) = p
|
|
816
|
+
|
|
817
|
+
port_execute_rows(sql, params)
|
|
818
|
+
|> Task.and_then((rows) -> { decode_rows(rows, decoder) })
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def decode_rows(rows: Value, decoder: Decoder(a)) -> Task(List(a), SqlError)
|
|
823
|
+
Decode.decode(Decode.list(decoder), rows)
|
|
824
|
+
|> Result.map_error((e) -> { DbError("row did not decode: " ++ show(e)) })
|
|
825
|
+
|> Task.from_result
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def exactly_one(rows: List(a)) -> Task(a, SqlError)
|
|
830
|
+
case rows
|
|
831
|
+
in [row] then Task.succeed(row)
|
|
832
|
+
in [] then Task.fail(NotFound)
|
|
833
|
+
else Task.fail(TooManyRows)
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
|
|
728
838
|
def execute(r: r) -> Task(Int, e)
|
|
729
839
|
to_sql(r) |> execute_raw |> Task.map_error(from_sql_error)
|
|
730
840
|
end
|
data/lib/jade-sql/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jade-sql
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- agustin
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: jade-lang
|