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.
- 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 +26 -12
- 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 +339 -89
- 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 -329
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,40 +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
|
-
|
|
55
|
+
neq,
|
|
56
|
+
no_joins,
|
|
57
|
+
not,
|
|
47
58
|
nullable,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
or,
|
|
60
|
+
pk,
|
|
61
|
+
set,
|
|
62
|
+
set_excluded,
|
|
63
|
+
set_expr,
|
|
51
64
|
sum,
|
|
52
65
|
table,
|
|
53
66
|
to_assigns,
|
|
54
|
-
to_expr,
|
|
55
67
|
transaction,
|
|
68
|
+
unique,
|
|
69
|
+
unkeyed,
|
|
70
|
+
unsafe_cast,
|
|
71
|
+
val,
|
|
72
|
+
within,
|
|
56
73
|
)
|
|
57
74
|
|
|
58
75
|
import Encode exposing (Encodable, encode)
|
|
@@ -72,21 +89,137 @@ struct Selector(a) = {
|
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
|
|
75
|
-
|
|
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) = {
|
|
76
103
|
name: String,
|
|
77
104
|
alias_: String,
|
|
78
105
|
cols: String -> c,
|
|
79
|
-
|
|
80
|
-
|
|
106
|
+
left_cols: String -> m,
|
|
107
|
+
set_cols: s,
|
|
108
|
+
pk: Pk(c, k),
|
|
109
|
+
on: o
|
|
81
110
|
}
|
|
82
111
|
|
|
83
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
|
+
|
|
84
133
|
struct TableRef = {
|
|
85
134
|
name: String,
|
|
86
135
|
alias_: String
|
|
87
136
|
}
|
|
88
137
|
|
|
89
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
|
+
|
|
90
223
|
struct Assignment = {
|
|
91
224
|
col: String,
|
|
92
225
|
value_sql: String,
|
|
@@ -94,8 +227,33 @@ struct Assignment = {
|
|
|
94
227
|
}
|
|
95
228
|
|
|
96
229
|
|
|
97
|
-
|
|
98
|
-
|
|
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, [])
|
|
99
257
|
end
|
|
100
258
|
|
|
101
259
|
|
|
@@ -104,34 +262,26 @@ def assign(col: String, value: a) -> Assignment
|
|
|
104
262
|
end
|
|
105
263
|
|
|
106
264
|
|
|
107
|
-
def strip_alias(sql: String) -> String
|
|
108
|
-
parts = String.split(sql, ".")
|
|
109
265
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
in [name] then name
|
|
113
|
-
in [_ | rest] then String.join(rest, ".")
|
|
114
|
-
end
|
|
266
|
+
interface Selectable(a) with
|
|
267
|
+
selector : Selector(a)
|
|
115
268
|
end
|
|
116
269
|
|
|
117
270
|
|
|
118
|
-
interface
|
|
271
|
+
interface Assignable(a) with
|
|
119
272
|
to_assigns : a -> List(Assignment)
|
|
120
273
|
end
|
|
121
274
|
|
|
122
275
|
|
|
123
|
-
implements
|
|
276
|
+
implements Assignable(List(Assignment)) with
|
|
124
277
|
to_assigns: (a) -> { a }
|
|
125
278
|
end
|
|
126
279
|
|
|
127
280
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
interface Renderable(r) with
|
|
134
|
-
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))
|
|
135
285
|
end
|
|
136
286
|
|
|
137
287
|
|
|
@@ -140,40 +290,60 @@ def column(alias_: String, name: String) -> Expr(a)
|
|
|
140
290
|
end
|
|
141
291
|
|
|
142
292
|
|
|
143
|
-
|
|
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)
|
|
144
303
|
Expr("?", [encode(value)])
|
|
145
304
|
end
|
|
146
305
|
|
|
147
306
|
|
|
148
|
-
# The
|
|
149
|
-
#
|
|
150
|
-
|
|
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)
|
|
151
314
|
Expr("now()", [])
|
|
152
315
|
end
|
|
153
316
|
|
|
154
317
|
|
|
155
|
-
def eq(
|
|
156
|
-
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)])
|
|
157
327
|
end
|
|
158
328
|
|
|
159
329
|
|
|
160
|
-
def gt(
|
|
161
|
-
Expr(
|
|
330
|
+
def gt(col: Expr(a), value: a) -> Expr(Bool)
|
|
331
|
+
Expr(col.sql ++ " > " ++ "?", col.params ++ [encode(value)])
|
|
162
332
|
end
|
|
163
333
|
|
|
164
334
|
|
|
165
|
-
def gte(
|
|
166
|
-
Expr(
|
|
335
|
+
def gte(col: Expr(a), value: a) -> Expr(Bool)
|
|
336
|
+
Expr(col.sql ++ " >= " ++ "?", col.params ++ [encode(value)])
|
|
167
337
|
end
|
|
168
338
|
|
|
169
339
|
|
|
170
|
-
def lt(
|
|
171
|
-
Expr(
|
|
340
|
+
def lt(col: Expr(a), value: a) -> Expr(Bool)
|
|
341
|
+
Expr(col.sql ++ " < " ++ "?", col.params ++ [encode(value)])
|
|
172
342
|
end
|
|
173
343
|
|
|
174
344
|
|
|
175
|
-
def lte(
|
|
176
|
-
Expr(
|
|
345
|
+
def lte(col: Expr(a), value: a) -> Expr(Bool)
|
|
346
|
+
Expr(col.sql ++ " <= " ++ "?", col.params ++ [encode(value)])
|
|
177
347
|
end
|
|
178
348
|
|
|
179
349
|
|
|
@@ -192,18 +362,58 @@ def nullable(e: Expr(a)) -> Expr(Maybe(a))
|
|
|
192
362
|
end
|
|
193
363
|
|
|
194
364
|
|
|
195
|
-
|
|
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)
|
|
196
374
|
Expr(e.sql, e.params)
|
|
197
375
|
end
|
|
198
376
|
|
|
199
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
|
+
|
|
200
399
|
def and(left: Expr(Bool), right: Expr(Bool)) -> Expr(Bool)
|
|
201
400
|
Expr(left.sql ++ " AND " ++ right.sql, left.params ++ right.params)
|
|
202
401
|
end
|
|
203
402
|
|
|
204
403
|
|
|
404
|
+
# Parenthesised where `and` is not, because `where` joins its predicates with
|
|
405
|
+
# AND: without them `x = 1 AND a OR b` binds as `(x = 1 AND a) OR b`, which is
|
|
406
|
+
# not what anyone writing `or` meant.
|
|
407
|
+
def or(left: Expr(Bool), right: Expr(Bool)) -> Expr(Bool)
|
|
408
|
+
Expr(
|
|
409
|
+
"(" ++ left.sql ++ " OR " ++ right.sql ++ ")",
|
|
410
|
+
left.params ++ right.params,
|
|
411
|
+
)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
|
|
205
415
|
# SUM may return NULL on an empty group, so the result is Expr(Maybe(Int)).
|
|
206
|
-
# Wrap with `coalesce(sum(x),
|
|
416
|
+
# Wrap with `coalesce(sum(x), 0)` for a strict total.
|
|
207
417
|
def sum(e: Expr(Int)) -> Expr(Maybe(Int))
|
|
208
418
|
Expr("SUM(" ++ e.sql ++ ")", e.params)
|
|
209
419
|
end
|
|
@@ -219,10 +429,10 @@ def count_all -> Expr(Int)
|
|
|
219
429
|
end
|
|
220
430
|
|
|
221
431
|
|
|
222
|
-
def coalesce(
|
|
432
|
+
def coalesce(col: Expr(Maybe(a)), default: a) -> Expr(a)
|
|
223
433
|
Expr(
|
|
224
|
-
"COALESCE(" ++
|
|
225
|
-
|
|
434
|
+
"COALESCE(" ++ col.sql ++ ", ?)",
|
|
435
|
+
col.params ++ [encode(default)],
|
|
226
436
|
)
|
|
227
437
|
end
|
|
228
438
|
|
|
@@ -285,8 +495,8 @@ def array_remove(col: Expr(List(a)), value: a) -> Expr(List(a))
|
|
|
285
495
|
end
|
|
286
496
|
|
|
287
497
|
|
|
288
|
-
def array_concat(
|
|
289
|
-
Expr(
|
|
498
|
+
def array_concat(col: Expr(List(a)), values: List(a)) -> Expr(List(a))
|
|
499
|
+
Expr(col.sql ++ " || ?", col.params ++ [encode(values)])
|
|
290
500
|
end
|
|
291
501
|
|
|
292
502
|
|
|
@@ -296,8 +506,14 @@ end
|
|
|
296
506
|
# jsonb_contains(column("rules", "match"), { kind: "income" })
|
|
297
507
|
#
|
|
298
508
|
# pg binds Ruby Hash/Array as jsonb when paired with a jsonb column.
|
|
299
|
-
#
|
|
300
|
-
#
|
|
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.
|
|
301
517
|
|
|
302
518
|
|
|
303
519
|
def jsonb_contains(col: Expr(Value), value: a) -> Expr(Bool)
|
|
@@ -306,11 +522,39 @@ end
|
|
|
306
522
|
|
|
307
523
|
|
|
308
524
|
def jsonb_path_exists(col: Expr(Value), path: String) -> Expr(Bool)
|
|
309
|
-
Expr(
|
|
525
|
+
Expr(
|
|
526
|
+
"jsonb_path_exists(" ++ col.sql ++ ", ?)",
|
|
527
|
+
col.params ++ [encode(path)],
|
|
528
|
+
)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
|
|
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
|
|
310
554
|
end
|
|
311
555
|
|
|
312
556
|
|
|
313
|
-
def
|
|
557
|
+
def any_of(col: Expr(a), values: List(a)) -> Expr(Bool)
|
|
314
558
|
case values
|
|
315
559
|
in [] then Expr("FALSE", [])
|
|
316
560
|
else in_nonempty(col, List.map(values, encode))
|
|
@@ -331,36 +575,48 @@ def table(
|
|
|
331
575
|
name: String,
|
|
332
576
|
alias_: String,
|
|
333
577
|
cols: String -> c,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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_)
|
|
338
584
|
end
|
|
339
585
|
|
|
340
586
|
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
343
597
|
end
|
|
344
598
|
|
|
345
599
|
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
348
604
|
end
|
|
349
605
|
|
|
350
606
|
|
|
351
|
-
def aliased(t: Table(c, m), alias_: String) -> Table(c, m)
|
|
352
|
-
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)
|
|
353
609
|
end
|
|
354
610
|
|
|
355
611
|
|
|
356
|
-
# `
|
|
357
|
-
#
|
|
358
|
-
#
|
|
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.
|
|
359
615
|
type SqlError
|
|
360
616
|
= DbError(String)
|
|
361
617
|
| NotFound
|
|
362
|
-
|
|
|
363
|
-
|
|
|
618
|
+
| TooManyRows
|
|
619
|
+
| UniqueViolation(String)
|
|
364
620
|
|
|
365
621
|
|
|
366
622
|
implements Encodable(SqlError) with
|
|
@@ -372,8 +628,8 @@ def encode_sql_error(e: SqlError) -> Value
|
|
|
372
628
|
case e
|
|
373
629
|
in DbError(msg) then Encode.variant("DbError", [Encode.string(msg)])
|
|
374
630
|
in NotFound then Encode.variant("NotFound", [])
|
|
375
|
-
in
|
|
376
|
-
in
|
|
631
|
+
in TooManyRows then Encode.variant("TooManyRows", [])
|
|
632
|
+
in UniqueViolation(name) then Encode.variant("UniqueViolation", [Encode.string(name)])
|
|
377
633
|
end
|
|
378
634
|
end
|
|
379
635
|
|
|
@@ -387,8 +643,8 @@ def sql_error_decoder -> Decoder(SqlError)
|
|
|
387
643
|
Decode.type_
|
|
388
644
|
|> Decode.variant("DbError", db_error_decoder)
|
|
389
645
|
|> Decode.variant("NotFound", Decode.succeed(NotFound))
|
|
390
|
-
|> Decode.variant("
|
|
391
|
-
|> Decode.variant("
|
|
646
|
+
|> Decode.variant("TooManyRows", Decode.succeed(TooManyRows))
|
|
647
|
+
|> Decode.variant("UniqueViolation", unique_violation_decoder)
|
|
392
648
|
end
|
|
393
649
|
|
|
394
650
|
|
|
@@ -397,8 +653,8 @@ def db_error_decoder -> Decoder(SqlError)
|
|
|
397
653
|
end
|
|
398
654
|
|
|
399
655
|
|
|
400
|
-
def
|
|
401
|
-
Decode.index(1, Decode.string) |> Decode.map(
|
|
656
|
+
def unique_violation_decoder -> Decoder(SqlError)
|
|
657
|
+
Decode.index(1, Decode.string) |> Decode.map(UniqueViolation)
|
|
402
658
|
end
|
|
403
659
|
|
|
404
660
|
|
|
@@ -431,24 +687,18 @@ end
|
|
|
431
687
|
|
|
432
688
|
|
|
433
689
|
def execute(r: r) -> Task(Int, SqlError)
|
|
434
|
-
|
|
690
|
+
to_sql(r) |> execute_raw
|
|
435
691
|
end
|
|
436
692
|
|
|
437
693
|
|
|
438
|
-
def fetch_one(r: r) -> Task(a, SqlError)
|
|
439
|
-
render(r) |> fetch_one_raw
|
|
440
|
-
end
|
|
441
694
|
|
|
442
695
|
|
|
443
|
-
def fetch_many(r: r) -> Task(List(a), SqlError)
|
|
444
|
-
render(r) |> fetch_many_raw
|
|
445
|
-
end
|
|
446
696
|
|
|
447
697
|
|
|
448
698
|
# Runs `task` inside a single DB transaction on the shared connection:
|
|
449
699
|
# every execute/fetch the task performs participates in it. Commits on
|
|
450
|
-
# Ok, rolls back and re-raises the error on Err.
|
|
451
|
-
#
|
|
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.
|
|
452
702
|
def transaction(task: Task(a, SqlError)) -> Task(a, SqlError)
|
|
453
703
|
port_begin()
|
|
454
704
|
|> Task.and_then((_) -> { commit_on_ok(task) })
|