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.
- checksums.yaml +4 -4
- data/README.md +16 -10
- data/docs/building.md +398 -96
- data/docs/running.md +37 -17
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +655 -44
- data/lib/jade-sql/compiler/assignable.rb +146 -0
- data/lib/jade-sql/compiler/columns.rb +220 -0
- data/lib/jade-sql/compiler/errors.rb +82 -0
- data/lib/jade-sql/compiler/selectable.rb +76 -0
- data/lib/jade-sql/compiler.rb +34 -0
- data/lib/jade-sql/runtime.rb +15 -11
- data/lib/jade-sql/schema_drift.rb +115 -0
- data/lib/jade-sql/sql/expr.jd +65 -0
- data/lib/jade-sql/sql/json.jd +154 -0
- data/lib/jade-sql/sql/query.jd +284 -92
- data/lib/jade-sql/sql/write.jd +625 -0
- data/lib/jade-sql/sql.jd +328 -90
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +8 -7
- metadata +16 -8
- data/lib/jade-sql/sql/mutation.jd +0 -464
data/lib/jade-sql/sql.jd
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
module Sql exposing (
|
|
2
|
+
Assignable,
|
|
3
|
+
Selectable,
|
|
4
|
+
selector,
|
|
2
5
|
Assignment(..),
|
|
6
|
+
Col(..),
|
|
3
7
|
Expr(..),
|
|
4
|
-
|
|
5
|
-
|
|
8
|
+
NoJoins,
|
|
9
|
+
NoKey,
|
|
10
|
+
NoRequiredCols,
|
|
11
|
+
Pk,
|
|
6
12
|
Selector(..),
|
|
7
13
|
SqlError(..),
|
|
8
|
-
SqlMapper,
|
|
9
14
|
Table,
|
|
10
15
|
TableRef(..),
|
|
16
|
+
Unique,
|
|
17
|
+
matching,
|
|
18
|
+
ToSql,
|
|
11
19
|
aliased,
|
|
12
20
|
and,
|
|
21
|
+
any_of,
|
|
13
22
|
array_append,
|
|
14
23
|
array_concat,
|
|
15
24
|
array_contained_by,
|
|
@@ -19,41 +28,48 @@ module Sql exposing (
|
|
|
19
28
|
array_overlaps,
|
|
20
29
|
array_remove,
|
|
21
30
|
assign,
|
|
22
|
-
cast,
|
|
23
31
|
coalesce,
|
|
24
32
|
column,
|
|
25
33
|
columns,
|
|
26
34
|
count,
|
|
27
35
|
count_all,
|
|
36
|
+
db_now,
|
|
28
37
|
eq,
|
|
38
|
+
excluded,
|
|
29
39
|
execute,
|
|
30
40
|
execute_raw,
|
|
31
|
-
fetch_many,
|
|
32
41
|
fetch_many_raw,
|
|
33
|
-
fetch_one,
|
|
34
42
|
fetch_one_raw,
|
|
35
43
|
gt,
|
|
36
44
|
gte,
|
|
37
|
-
|
|
45
|
+
ilike,
|
|
38
46
|
is_not_null,
|
|
39
47
|
is_null,
|
|
40
48
|
jsonb_contains,
|
|
41
49
|
jsonb_path_exists,
|
|
50
|
+
left_columns,
|
|
51
|
+
like,
|
|
42
52
|
lt,
|
|
43
53
|
lte,
|
|
44
|
-
maybe_columns,
|
|
45
54
|
neg,
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
neq,
|
|
56
|
+
no_joins,
|
|
57
|
+
not,
|
|
48
58
|
nullable,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
or,
|
|
60
|
+
pk,
|
|
61
|
+
set,
|
|
62
|
+
set_excluded,
|
|
63
|
+
set_expr,
|
|
52
64
|
sum,
|
|
53
65
|
table,
|
|
54
66
|
to_assigns,
|
|
55
|
-
to_expr,
|
|
56
67
|
transaction,
|
|
68
|
+
unique,
|
|
69
|
+
unkeyed,
|
|
70
|
+
unsafe_cast,
|
|
71
|
+
val,
|
|
72
|
+
within,
|
|
57
73
|
)
|
|
58
74
|
|
|
59
75
|
import Encode exposing (Encodable, encode)
|
|
@@ -73,21 +89,137 @@ struct Selector(a) = {
|
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
|
|
76
|
-
|
|
92
|
+
# A table, as `structure.sql` declares it. `c` is its strict column struct
|
|
93
|
+
# (`Expr(String)` for a NOT NULL text column), `m` the same columns lifted into
|
|
94
|
+
# `Maybe` for left-join projections, `k` the type of its primary key, `o` a
|
|
95
|
+
# record of one join predicate per foreign key it declares, `r` a struct of the
|
|
96
|
+
# columns an insert has to write, and `s` a record of `Col` accessors for the
|
|
97
|
+
# left of a `SET`.
|
|
98
|
+
#
|
|
99
|
+
# `schema.jd` generates one of these per table, so you name the table rather
|
|
100
|
+
# than build it: `from(patients)`, `insert(row, patients)`. Reach for `table`
|
|
101
|
+
# yourself only for something the generator does not see.
|
|
102
|
+
struct Table(c, m, k, o, r, s) = {
|
|
77
103
|
name: String,
|
|
78
104
|
alias_: String,
|
|
79
105
|
cols: String -> c,
|
|
80
|
-
|
|
81
|
-
|
|
106
|
+
left_cols: String -> m,
|
|
107
|
+
set_cols: s,
|
|
108
|
+
pk: Pk(c, k),
|
|
109
|
+
on: o
|
|
82
110
|
}
|
|
83
111
|
|
|
84
112
|
|
|
113
|
+
# The `on` of a table that declares no foreign keys. There is no relation to
|
|
114
|
+
# name, so there is no join predicate to reach for — join such a table by
|
|
115
|
+
# writing the predicate yourself:
|
|
116
|
+
#
|
|
117
|
+
# from(patients) |> join(events, (e) -> { p.id |> Expr.eq(e.patient_id) })
|
|
118
|
+
type NoJoins = NoJoinsOnly
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# The `r` of a table an insert can leave entirely to the database — every
|
|
122
|
+
# column is nullable, or has a default, or is filled by its sequence. There is
|
|
123
|
+
# no struct of required columns to name, so there is nothing to write.
|
|
124
|
+
type NoRequiredCols = NoRequiredColsOnly
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# The `on` you hand `table` for such a table.
|
|
128
|
+
def no_joins -> NoJoins
|
|
129
|
+
NoJoinsOnly
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
|
|
85
133
|
struct TableRef = {
|
|
86
134
|
name: String,
|
|
87
135
|
alias_: String
|
|
88
136
|
}
|
|
89
137
|
|
|
90
138
|
|
|
139
|
+
# A table's primary key: the columns that make it up, and how to spread a key
|
|
140
|
+
# value across them. `k` is what you pass to `update` and `delete` — `Int` for
|
|
141
|
+
# a serial id, `Uuid` for a uuid one, a tuple like `(Int, Int)` for a composite
|
|
142
|
+
# key, in the order `structure.sql` declares the columns.
|
|
143
|
+
#
|
|
144
|
+
# Phantom in `c`, the table's column struct, so a key belongs to the one table
|
|
145
|
+
# it came from and cannot be passed to another. Both halves are generated from
|
|
146
|
+
# the same DDL, so the columns and the values cannot fall out of step.
|
|
147
|
+
struct Pk(c, k) = {
|
|
148
|
+
name: String,
|
|
149
|
+
columns: List(String),
|
|
150
|
+
values: k -> List(Value)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# The key type of a table that has none — a join table, an append-only log,
|
|
155
|
+
# anything `structure.sql` declares without a PRIMARY KEY. `NoKey` has no
|
|
156
|
+
# constructor you can reach, so there is no value to pass where a key is
|
|
157
|
+
# expected.
|
|
158
|
+
type NoKey = NoKey
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# The primary key of a table: the constraint that declares it, its key columns
|
|
162
|
+
# in DDL order, and how to spread a key value across them.
|
|
163
|
+
#
|
|
164
|
+
# pk("patients_pkey", ["id"], (v) -> { [Encode.encode(v)] })
|
|
165
|
+
def pk(name: String, key_cols: List(String), values: k -> List(Value)) -> Pk(c, k)
|
|
166
|
+
Pk(name, key_cols, values)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# The `Pk` of a table with no primary key. Reaching for it means giving up
|
|
171
|
+
# `update` and `delete`, which take a key; write to the table with
|
|
172
|
+
# `update_all`/`delete_all`, which take a predicate:
|
|
173
|
+
#
|
|
174
|
+
# delete_all(events, (c) -> { c.recorded_at |> lt(cutoff) })
|
|
175
|
+
#
|
|
176
|
+
# The generator passes this for you. You need it by hand only when writing a
|
|
177
|
+
# table function yourself.
|
|
178
|
+
def unkeyed -> Pk(c, NoKey)
|
|
179
|
+
Pk("", [], (v) -> { [] })
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# A column on the left of a `SET`, carrying the name rather than an
|
|
184
|
+
# expression that happens to look like one. `set` reads the name off it, so
|
|
185
|
+
# there is nothing to recover from a rendered string and nothing to get wrong:
|
|
186
|
+
# an aggregate or a `COALESCE` cannot be assigned to, and now cannot be
|
|
187
|
+
# offered.
|
|
188
|
+
#
|
|
189
|
+
# The generator emits one record of these per table, which `update_all` hands
|
|
190
|
+
# to the builder alongside the column accessors.
|
|
191
|
+
struct Col(a) = { name: String }
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# A unique index, by the name Postgres reports when it is violated. Phantom
|
|
195
|
+
# in the column struct, so an index cannot be used with a table it is not on.
|
|
196
|
+
# The generator emits one per unique index and per table-level UNIQUE.
|
|
197
|
+
struct Unique(c, k) = {
|
|
198
|
+
name: String,
|
|
199
|
+
columns: List(String),
|
|
200
|
+
values: k -> List(Value)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def unique(name: String, cols: List(String), values: k -> List(Value)) -> Unique(c, k)
|
|
205
|
+
Unique(name, cols, values)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# `email = ?`, or every column of a composite index joined by AND. The key
|
|
211
|
+
# type comes from the index, so the wrong one does not compile and a
|
|
212
|
+
# composite cannot be given in the wrong order.
|
|
213
|
+
def matching(u: Unique(c, k), key: k) -> Expr(Bool)
|
|
214
|
+
Expr(
|
|
215
|
+
u.columns
|
|
216
|
+
|> List.map((col) -> { col ++ " = ?" })
|
|
217
|
+
|> String.join(" AND "),
|
|
218
|
+
key |> u.values,
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
|
|
91
223
|
struct Assignment = {
|
|
92
224
|
col: String,
|
|
93
225
|
value_sql: String,
|
|
@@ -95,8 +227,33 @@ struct Assignment = {
|
|
|
95
227
|
}
|
|
96
228
|
|
|
97
229
|
|
|
98
|
-
|
|
99
|
-
|
|
230
|
+
# The assignment `update` and `update_all` take, given a column accessor and
|
|
231
|
+
# the value to write:
|
|
232
|
+
#
|
|
233
|
+
# [c.archived |> set(True)]
|
|
234
|
+
def set(col: Col(a), value: a) -> Assignment
|
|
235
|
+
Assignment(col.name, "?", [encode(value)])
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# `set`, where the new value is built from the row rather than held by the
|
|
240
|
+
# caller:
|
|
241
|
+
#
|
|
242
|
+
# [c.tags |> set_expr(array_append(c.tags, tag))]
|
|
243
|
+
def set_expr(col: Col(a), value: Expr(a)) -> Assignment
|
|
244
|
+
Assignment(col.name, value.sql, value.params)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# The row that could not be inserted, for the `DO UPDATE` arm of an upsert.
|
|
249
|
+
def excluded(col: Col(a)) -> Expr(a)
|
|
250
|
+
Expr("EXCLUDED." ++ col.name, [])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# `email = EXCLUDED.email`, which is what an upsert wants nearly every time.
|
|
255
|
+
def set_excluded(col: Col(a)) -> Assignment
|
|
256
|
+
Assignment(col.name, "EXCLUDED." ++ col.name, [])
|
|
100
257
|
end
|
|
101
258
|
|
|
102
259
|
|
|
@@ -105,34 +262,26 @@ def assign(col: String, value: a) -> Assignment
|
|
|
105
262
|
end
|
|
106
263
|
|
|
107
264
|
|
|
108
|
-
def strip_alias(sql: String) -> String
|
|
109
|
-
parts = String.split(sql, ".")
|
|
110
265
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
in [name] then name
|
|
114
|
-
in [_ | rest] then String.join(rest, ".")
|
|
115
|
-
end
|
|
266
|
+
interface Selectable(a) with
|
|
267
|
+
selector : Selector(a)
|
|
116
268
|
end
|
|
117
269
|
|
|
118
270
|
|
|
119
|
-
interface
|
|
271
|
+
interface Assignable(a) with
|
|
120
272
|
to_assigns : a -> List(Assignment)
|
|
121
273
|
end
|
|
122
274
|
|
|
123
275
|
|
|
124
|
-
implements
|
|
276
|
+
implements Assignable(List(Assignment)) with
|
|
125
277
|
to_assigns: (a) -> { a }
|
|
126
278
|
end
|
|
127
279
|
|
|
128
280
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
interface Renderable(r) with
|
|
135
|
-
render : r -> (String, List(Value))
|
|
281
|
+
# Anything that can be handed to the database: a query, a write. The pair is
|
|
282
|
+
# the statement and its parameters, in the order the statement binds them.
|
|
283
|
+
interface ToSql(r) with
|
|
284
|
+
to_sql : r -> (String, List(Value))
|
|
136
285
|
end
|
|
137
286
|
|
|
138
287
|
|
|
@@ -141,40 +290,60 @@ def column(alias_: String, name: String) -> Expr(a)
|
|
|
141
290
|
end
|
|
142
291
|
|
|
143
292
|
|
|
144
|
-
|
|
293
|
+
# A value where an expression is wanted. The operators take values directly,
|
|
294
|
+
# so this is for the positions that cannot: a constant field in a projection
|
|
295
|
+
# or a JSON document.
|
|
296
|
+
#
|
|
297
|
+
# select(Row(_, _)) |> field(c.id) |> field_as(val("patient"), "kind")
|
|
298
|
+
# Json.object(Row(_)) |> Json.prop("kind", val("patient"))
|
|
299
|
+
#
|
|
300
|
+
# Every library that has this calls it `val`, and none of them has a word for
|
|
301
|
+
# the other direction, since there the expression side is the default.
|
|
302
|
+
def val(value: a) -> Expr(a)
|
|
145
303
|
Expr("?", [encode(value)])
|
|
146
304
|
end
|
|
147
305
|
|
|
148
306
|
|
|
149
|
-
# The
|
|
150
|
-
#
|
|
151
|
-
|
|
307
|
+
# The database's clock, as `now()` in the statement — evaluated by Postgres
|
|
308
|
+
# when the query runs, at the start of the enclosing transaction. Not the
|
|
309
|
+
# app's clock: `Sql.Write.timestamped` and `stamped` use that one, so two
|
|
310
|
+
# rows written by one request share an instant this would not give them.
|
|
311
|
+
#
|
|
312
|
+
# where(s.expires_at |> Expr.gt(db_now))
|
|
313
|
+
def db_now -> Expr(Instant)
|
|
152
314
|
Expr("now()", [])
|
|
153
315
|
end
|
|
154
316
|
|
|
155
317
|
|
|
156
|
-
def eq(
|
|
157
|
-
Expr(
|
|
318
|
+
def eq(col: Expr(a), value: a) -> Expr(Bool)
|
|
319
|
+
Expr(col.sql ++ " = " ++ "?", col.params ++ [encode(value)])
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# `NULL <> x` is NULL, not true, so a nullable column never matches this.
|
|
324
|
+
# Pair it with `is_null` when "different, or absent" is what you mean.
|
|
325
|
+
def neq(col: Expr(a), value: a) -> Expr(Bool)
|
|
326
|
+
Expr(col.sql ++ " <> " ++ "?", col.params ++ [encode(value)])
|
|
158
327
|
end
|
|
159
328
|
|
|
160
329
|
|
|
161
|
-
def gt(
|
|
162
|
-
Expr(
|
|
330
|
+
def gt(col: Expr(a), value: a) -> Expr(Bool)
|
|
331
|
+
Expr(col.sql ++ " > " ++ "?", col.params ++ [encode(value)])
|
|
163
332
|
end
|
|
164
333
|
|
|
165
334
|
|
|
166
|
-
def gte(
|
|
167
|
-
Expr(
|
|
335
|
+
def gte(col: Expr(a), value: a) -> Expr(Bool)
|
|
336
|
+
Expr(col.sql ++ " >= " ++ "?", col.params ++ [encode(value)])
|
|
168
337
|
end
|
|
169
338
|
|
|
170
339
|
|
|
171
|
-
def lt(
|
|
172
|
-
Expr(
|
|
340
|
+
def lt(col: Expr(a), value: a) -> Expr(Bool)
|
|
341
|
+
Expr(col.sql ++ " < " ++ "?", col.params ++ [encode(value)])
|
|
173
342
|
end
|
|
174
343
|
|
|
175
344
|
|
|
176
|
-
def lte(
|
|
177
|
-
Expr(
|
|
345
|
+
def lte(col: Expr(a), value: a) -> Expr(Bool)
|
|
346
|
+
Expr(col.sql ++ " <= " ++ "?", col.params ++ [encode(value)])
|
|
178
347
|
end
|
|
179
348
|
|
|
180
349
|
|
|
@@ -193,11 +362,40 @@ def nullable(e: Expr(a)) -> Expr(Maybe(a))
|
|
|
193
362
|
end
|
|
194
363
|
|
|
195
364
|
|
|
196
|
-
|
|
365
|
+
# Re-labels what a column expression claims to hold, without touching the SQL
|
|
366
|
+
# or the value. Nothing converts, and nothing checks: if the database returns
|
|
367
|
+
# something the new type cannot decode, the failure lands at decode time.
|
|
368
|
+
#
|
|
369
|
+
# Reach for it where the schema is less specific than the code, and the code
|
|
370
|
+
# is right — a `text` column the application only ever writes enum labels to:
|
|
371
|
+
#
|
|
372
|
+
# select(Visit(_)) |> field(v.status |> unsafe_cast)
|
|
373
|
+
def unsafe_cast(e: Expr(a)) -> Expr(b)
|
|
197
374
|
Expr(e.sql, e.params)
|
|
198
375
|
end
|
|
199
376
|
|
|
200
377
|
|
|
378
|
+
def not(e: Expr(Bool)) -> Expr(Bool)
|
|
379
|
+
# `NOT a AND b` binds as `(NOT a) AND b`.
|
|
380
|
+
Expr("NOT (" ++ e.sql ++ ")", e.params)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# `LIKE` is case-sensitive, `ILIKE` is not. Neither escapes `%` or `_` in the
|
|
385
|
+
# pattern — a user-supplied string containing either is a wildcard, so escape
|
|
386
|
+
# it yourself before it gets here.
|
|
387
|
+
#
|
|
388
|
+
# c.name |> like("Ada%")
|
|
389
|
+
def like(col: Expr(String), pattern: String) -> Expr(Bool)
|
|
390
|
+
Expr(col.sql ++ " LIKE " ++ "?", col.params ++ [encode(pattern)])
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def ilike(col: Expr(String), pattern: String) -> Expr(Bool)
|
|
395
|
+
Expr(col.sql ++ " ILIKE " ++ "?", col.params ++ [encode(pattern)])
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
|
|
201
399
|
def and(left: Expr(Bool), right: Expr(Bool)) -> Expr(Bool)
|
|
202
400
|
Expr(left.sql ++ " AND " ++ right.sql, left.params ++ right.params)
|
|
203
401
|
end
|
|
@@ -215,7 +413,7 @@ end
|
|
|
215
413
|
|
|
216
414
|
|
|
217
415
|
# SUM may return NULL on an empty group, so the result is Expr(Maybe(Int)).
|
|
218
|
-
# Wrap with `coalesce(sum(x),
|
|
416
|
+
# Wrap with `coalesce(sum(x), 0)` for a strict total.
|
|
219
417
|
def sum(e: Expr(Int)) -> Expr(Maybe(Int))
|
|
220
418
|
Expr("SUM(" ++ e.sql ++ ")", e.params)
|
|
221
419
|
end
|
|
@@ -231,10 +429,10 @@ def count_all -> Expr(Int)
|
|
|
231
429
|
end
|
|
232
430
|
|
|
233
431
|
|
|
234
|
-
def coalesce(
|
|
432
|
+
def coalesce(col: Expr(Maybe(a)), default: a) -> Expr(a)
|
|
235
433
|
Expr(
|
|
236
|
-
"COALESCE(" ++
|
|
237
|
-
|
|
434
|
+
"COALESCE(" ++ col.sql ++ ", ?)",
|
|
435
|
+
col.params ++ [encode(default)],
|
|
238
436
|
)
|
|
239
437
|
end
|
|
240
438
|
|
|
@@ -297,8 +495,8 @@ def array_remove(col: Expr(List(a)), value: a) -> Expr(List(a))
|
|
|
297
495
|
end
|
|
298
496
|
|
|
299
497
|
|
|
300
|
-
def array_concat(
|
|
301
|
-
Expr(
|
|
498
|
+
def array_concat(col: Expr(List(a)), values: List(a)) -> Expr(List(a))
|
|
499
|
+
Expr(col.sql ++ " || ?", col.params ++ [encode(values)])
|
|
302
500
|
end
|
|
303
501
|
|
|
304
502
|
|
|
@@ -308,8 +506,14 @@ end
|
|
|
308
506
|
# jsonb_contains(column("rules", "match"), { kind: "income" })
|
|
309
507
|
#
|
|
310
508
|
# pg binds Ruby Hash/Array as jsonb when paired with a jsonb column.
|
|
311
|
-
#
|
|
312
|
-
#
|
|
509
|
+
#
|
|
510
|
+
# Four of Postgres' jsonb operators spell themselves with a `?` — `@?`, `?`,
|
|
511
|
+
# `?|` and `?&` — and the runtime rewrites every `?` outside a quoted span
|
|
512
|
+
# into a `$n` placeholder. It cannot tell an operator from a parameter, since
|
|
513
|
+
# `meta ? 'kind'` and `id = ?` are the same character in the same position.
|
|
514
|
+
# So these render as the functions Postgres gives them instead, which contain
|
|
515
|
+
# no `?` and need no escaping. The functions also declare their parameter
|
|
516
|
+
# types, which is what the operator form needed a cast to supply.
|
|
313
517
|
|
|
314
518
|
|
|
315
519
|
def jsonb_contains(col: Expr(Value), value: a) -> Expr(Bool)
|
|
@@ -318,11 +522,39 @@ end
|
|
|
318
522
|
|
|
319
523
|
|
|
320
524
|
def jsonb_path_exists(col: Expr(Value), path: String) -> Expr(Bool)
|
|
321
|
-
Expr(
|
|
525
|
+
Expr(
|
|
526
|
+
"jsonb_path_exists(" ++ col.sql ++ ", ?)",
|
|
527
|
+
col.params ++ [encode(path)],
|
|
528
|
+
)
|
|
322
529
|
end
|
|
323
530
|
|
|
324
531
|
|
|
325
|
-
|
|
532
|
+
# A column against a range, as `a..b` builds one. Both ends are inclusive,
|
|
533
|
+
# matching `Range.contains?`, so the whole span renders as SQL rather than
|
|
534
|
+
# being tested row by row:
|
|
535
|
+
#
|
|
536
|
+
# where(c.seen_on |> within(start..finish))
|
|
537
|
+
# where(c.seen_on |> within(Range.from(start)))
|
|
538
|
+
#
|
|
539
|
+
# The degenerate ends render as constants: an empty range is `FALSE`, an
|
|
540
|
+
# unbounded one `TRUE`, the same way `any_of([])` is `FALSE`.
|
|
541
|
+
def within(col: Expr(a), range: Range(a)) -> Expr(Bool)
|
|
542
|
+
case (Range.empty?(range), Range.lower(range), Range.upper(range))
|
|
543
|
+
in (True, _, _) then Expr("FALSE", [])
|
|
544
|
+
|
|
545
|
+
in (_, Just(low), Just(high))
|
|
546
|
+
then Expr(col.sql ++ " BETWEEN ? AND ?", col.params ++ [encode(low), encode(high)])
|
|
547
|
+
|
|
548
|
+
in (_, Just(low), Nothing) then Expr(col.sql ++ " >= ?", col.params ++ [encode(low)])
|
|
549
|
+
|
|
550
|
+
in (_, Nothing, Just(high)) then Expr(col.sql ++ " <= ?", col.params ++ [encode(high)])
|
|
551
|
+
|
|
552
|
+
in (_, Nothing, Nothing) then Expr("TRUE", [])
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def any_of(col: Expr(a), values: List(a)) -> Expr(Bool)
|
|
326
558
|
case values
|
|
327
559
|
in [] then Expr("FALSE", [])
|
|
328
560
|
else in_nonempty(col, List.map(values, encode))
|
|
@@ -343,36 +575,48 @@ def table(
|
|
|
343
575
|
name: String,
|
|
344
576
|
alias_: String,
|
|
345
577
|
cols: String -> c,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
578
|
+
left_cols: String -> m,
|
|
579
|
+
set_cols: s,
|
|
580
|
+
pk_: Pk(c, k),
|
|
581
|
+
on_: o,
|
|
582
|
+
) -> Table(c, m, k, o, r, s)
|
|
583
|
+
Table(name, alias_, cols, left_cols, set_cols, pk_, on_)
|
|
350
584
|
end
|
|
351
585
|
|
|
352
586
|
|
|
353
|
-
|
|
354
|
-
|
|
587
|
+
# A table's columns, bound to the alias the table carries. Reach for it to
|
|
588
|
+
# build a predicate outside a bind chain, where `from` has not handed you the
|
|
589
|
+
# columns yet:
|
|
590
|
+
#
|
|
591
|
+
# p = columns(persons)
|
|
592
|
+
# from(persons) |> where(p.name |> eq("Paul"))
|
|
593
|
+
#
|
|
594
|
+
# Use `aliased` first to read the same table under another name.
|
|
595
|
+
def columns(t: Table(c, m, k, o, r, s)) -> c
|
|
596
|
+
t.alias_ |> t.cols
|
|
355
597
|
end
|
|
356
598
|
|
|
357
599
|
|
|
358
|
-
|
|
359
|
-
|
|
600
|
+
# The same columns as a left join produces them, every one lifted into
|
|
601
|
+
# `Maybe`.
|
|
602
|
+
def left_columns(t: Table(c, m, k, o, r, s)) -> m
|
|
603
|
+
t.alias_ |> t.left_cols
|
|
360
604
|
end
|
|
361
605
|
|
|
362
606
|
|
|
363
|
-
def aliased(t: Table(c, m), alias_: String) -> Table(c, m)
|
|
364
|
-
Table(t.name, alias_, t.cols, t.
|
|
607
|
+
def aliased(t: Table(c, m, k, o, r, s), alias_: String) -> Table(c, m, k, o, r, s)
|
|
608
|
+
Table(t.name, alias_, t.cols, t.left_cols, t.set_cols, t.pk, t.on)
|
|
365
609
|
end
|
|
366
610
|
|
|
367
611
|
|
|
368
|
-
# `
|
|
369
|
-
#
|
|
370
|
-
#
|
|
612
|
+
# `UniqueViolation` carries the violated constraint name. Compare it against
|
|
613
|
+
# a generated index rather than a literal: `i == users_email_key.name` stops
|
|
614
|
+
# compiling when the index is renamed, where a literal quietly stops matching.
|
|
371
615
|
type SqlError
|
|
372
616
|
= DbError(String)
|
|
373
617
|
| NotFound
|
|
374
|
-
|
|
|
375
|
-
|
|
|
618
|
+
| TooManyRows
|
|
619
|
+
| UniqueViolation(String)
|
|
376
620
|
|
|
377
621
|
|
|
378
622
|
implements Encodable(SqlError) with
|
|
@@ -384,8 +628,8 @@ def encode_sql_error(e: SqlError) -> Value
|
|
|
384
628
|
case e
|
|
385
629
|
in DbError(msg) then Encode.variant("DbError", [Encode.string(msg)])
|
|
386
630
|
in NotFound then Encode.variant("NotFound", [])
|
|
387
|
-
in
|
|
388
|
-
in
|
|
631
|
+
in TooManyRows then Encode.variant("TooManyRows", [])
|
|
632
|
+
in UniqueViolation(name) then Encode.variant("UniqueViolation", [Encode.string(name)])
|
|
389
633
|
end
|
|
390
634
|
end
|
|
391
635
|
|
|
@@ -399,8 +643,8 @@ def sql_error_decoder -> Decoder(SqlError)
|
|
|
399
643
|
Decode.type_
|
|
400
644
|
|> Decode.variant("DbError", db_error_decoder)
|
|
401
645
|
|> Decode.variant("NotFound", Decode.succeed(NotFound))
|
|
402
|
-
|> Decode.variant("
|
|
403
|
-
|> Decode.variant("
|
|
646
|
+
|> Decode.variant("TooManyRows", Decode.succeed(TooManyRows))
|
|
647
|
+
|> Decode.variant("UniqueViolation", unique_violation_decoder)
|
|
404
648
|
end
|
|
405
649
|
|
|
406
650
|
|
|
@@ -409,8 +653,8 @@ def db_error_decoder -> Decoder(SqlError)
|
|
|
409
653
|
end
|
|
410
654
|
|
|
411
655
|
|
|
412
|
-
def
|
|
413
|
-
Decode.index(1, Decode.string) |> Decode.map(
|
|
656
|
+
def unique_violation_decoder -> Decoder(SqlError)
|
|
657
|
+
Decode.index(1, Decode.string) |> Decode.map(UniqueViolation)
|
|
414
658
|
end
|
|
415
659
|
|
|
416
660
|
|
|
@@ -443,24 +687,18 @@ end
|
|
|
443
687
|
|
|
444
688
|
|
|
445
689
|
def execute(r: r) -> Task(Int, SqlError)
|
|
446
|
-
|
|
690
|
+
to_sql(r) |> execute_raw
|
|
447
691
|
end
|
|
448
692
|
|
|
449
693
|
|
|
450
|
-
def fetch_one(r: r) -> Task(a, SqlError)
|
|
451
|
-
render(r) |> fetch_one_raw
|
|
452
|
-
end
|
|
453
694
|
|
|
454
695
|
|
|
455
|
-
def fetch_many(r: r) -> Task(List(a), SqlError)
|
|
456
|
-
render(r) |> fetch_many_raw
|
|
457
|
-
end
|
|
458
696
|
|
|
459
697
|
|
|
460
698
|
# Runs `task` inside a single DB transaction on the shared connection:
|
|
461
699
|
# every execute/fetch the task performs participates in it. Commits on
|
|
462
|
-
# Ok, rolls back and re-raises the error on Err.
|
|
463
|
-
#
|
|
700
|
+
# Ok, rolls back and re-raises the error on Err. Nests as a savepoint of
|
|
701
|
+
# whichever transaction is already open, jade's or ActiveRecord's.
|
|
464
702
|
def transaction(task: Task(a, SqlError)) -> Task(a, SqlError)
|
|
465
703
|
port_begin()
|
|
466
704
|
|> Task.and_then((_) -> { commit_on_ok(task) })
|