jade-sql 0.8.0 → 0.9.1
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 +37 -41
- data/docs/running.md +74 -9
- data/lib/jade-sql/bin/generate_schema.rb +47 -0
- data/lib/jade-sql/compiler.rb +0 -2
- data/lib/jade-sql/runtime.rb +65 -19
- data/lib/jade-sql/sql/json.jd +2 -2
- data/lib/jade-sql/sql/query.jd +99 -75
- data/lib/jade-sql/sql/write.jd +7 -14
- data/lib/jade-sql/sql.jd +75 -27
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +88 -11
- metadata +4 -5
- data/lib/jade-sql/compiler/selectable.rb +0 -76
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d437d0d6f0732ff240bc1833dd724ab0ddb17f11f048f64484971d4986c189a
|
|
4
|
+
data.tar.gz: 7570a5c8616b4281d64535e577dd9c70a504c285197ede6bda39e07b1061e397
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88fd20a47981bd6735bee0d63fcb65d950c34da7b4deaa5f4d132f6d8342a2fe7c0f9db78628fbdfe043e001262e1751eec24528eef449234f0746076b791304
|
|
7
|
+
data.tar.gz: aab1dbb9f6a251c3b61827bd605cb89ac81ca783f13f421db0093831c294556a863e3321e75e39531cc204a25693be8434a7caf3535b978b0712e100cc6454ff
|
data/docs/building.md
CHANGED
|
@@ -281,8 +281,8 @@ select(Busy(_))
|
|
|
281
281
|
`distinct(q)` drops duplicate rows from the whole projected row, which is
|
|
282
282
|
`SELECT DISTINCT` rather than Postgres' `DISTINCT ON`.
|
|
283
283
|
|
|
284
|
-
`exists(q)`
|
|
285
|
-
|
|
284
|
+
`exists(q)` asks whether a related row is there, without joining to it and
|
|
285
|
+
without projecting anything from it; `not(exists(q))` is the negative. The inner query may
|
|
286
286
|
name the outer query's columns, which is what makes it correlated:
|
|
287
287
|
|
|
288
288
|
```jade
|
|
@@ -404,13 +404,13 @@ escape hatch until they get a typed builder.
|
|
|
404
404
|
|
|
405
405
|
### Subqueries
|
|
406
406
|
|
|
407
|
-
`exists`
|
|
408
|
-
|
|
409
|
-
|
|
407
|
+
`exists` asks whether a related row is there, `subquery` reads a single value
|
|
408
|
+
out of one, and `in_subquery` matches a column against one a subquery selects.
|
|
409
|
+
All three take an unprojected `Query`.
|
|
410
410
|
|
|
411
411
|
Each is named for the SQL it renders. There is no `not_in`: `NOT IN` against a
|
|
412
|
-
subquery yielding a NULL returns no rows at all, so `
|
|
413
|
-
reach for.
|
|
412
|
+
subquery yielding a NULL returns no rows at all, so `not(exists(q))` is the
|
|
413
|
+
form to reach for.
|
|
414
414
|
|
|
415
415
|
```jade
|
|
416
416
|
import Sql.Query exposing (
|
|
@@ -418,7 +418,6 @@ import Sql.Query exposing (
|
|
|
418
418
|
in_subquery,
|
|
419
419
|
limit,
|
|
420
420
|
order_desc,
|
|
421
|
-
rows,
|
|
422
421
|
subquery,
|
|
423
422
|
where,
|
|
424
423
|
)
|
|
@@ -426,7 +425,7 @@ import Sql.Query exposing (
|
|
|
426
425
|
def latest(p: PatientsCols) -> Query(VisitsCols)
|
|
427
426
|
v <- from(visits)
|
|
428
427
|
|
|
429
|
-
|
|
428
|
+
from(visits)
|
|
430
429
|
|> where(v.patient_id |> Expr.eq(p.id))
|
|
431
430
|
|> order_desc(v.seen_on)
|
|
432
431
|
|> limit(1)
|
|
@@ -442,14 +441,13 @@ where(p.id |> in_subquery(from(visits), .patient_id))
|
|
|
442
441
|
# WHERE p.id IN (SELECT v.patient_id FROM visits v)
|
|
443
442
|
```
|
|
444
443
|
|
|
445
|
-
`
|
|
446
|
-
table's columns rather than a projection
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
is
|
|
452
|
-
function rather than projected, because
|
|
444
|
+
A subquery starts with `from(t)`, the same as any other query, and carries the
|
|
445
|
+
table's columns rather than a projection. Naming the table again is what makes
|
|
446
|
+
the subquery stand on its own: one that borrowed the outer query's columns
|
|
447
|
+
would render without a `FROM` and read the outer table instead, which is legal
|
|
448
|
+
SQL asking a different question. Naming a table the chain already bound costs
|
|
449
|
+
nothing, since a table is listed once however many times it is named. The
|
|
450
|
+
column is picked by a function rather than projected, because
|
|
453
451
|
`Select(a)` does not say how many columns it has, and a subquery in a value
|
|
454
452
|
position may only have one. `subquery` returns `Expr(Maybe(a))`, since a
|
|
455
453
|
subquery matching no rows is NULL.
|
|
@@ -682,23 +680,22 @@ a `SET` takes no alias.
|
|
|
682
680
|
|
|
683
681
|
### RETURNING
|
|
684
682
|
|
|
685
|
-
`
|
|
686
|
-
It
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
empty Query state.
|
|
683
|
+
`returning_with` names the columns coming back, from the table's accessors.
|
|
684
|
+
It is a step of its own rather than something `fetch_one` does for you: a
|
|
685
|
+
write has one type whether you run it for a count or for a row, so folding the
|
|
686
|
+
projection into the runner would mean `execute` and `fetch_one` producing
|
|
687
|
+
different SQL from the same `Write`.
|
|
691
688
|
|
|
692
689
|
```jade
|
|
693
690
|
import Sql exposing(Selector)
|
|
694
691
|
import Sql.Query exposing(select, field)
|
|
695
|
-
import Sql.Write exposing(insert,
|
|
692
|
+
import Sql.Write exposing(insert, returning_with, to_sql)
|
|
696
693
|
|
|
697
694
|
# INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
698
695
|
# RETURNING patients.id, patients.name, patients.mrn
|
|
699
696
|
np
|
|
700
697
|
|> insert(patients)
|
|
701
|
-
|>
|
|
698
|
+
|> returning_with((p) -> {
|
|
702
699
|
select(Patient(_, _, _))
|
|
703
700
|
|> field(p.id)
|
|
704
701
|
|> field(p.name)
|
|
@@ -707,27 +704,26 @@ np
|
|
|
707
704
|
|> to_sql # or |> fetch_one to run
|
|
708
705
|
```
|
|
709
706
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
works for `from(patients) |> patient_projector` (query) and
|
|
714
|
-
`... |> returning(patient_projector)` (RETURNING).
|
|
715
|
-
|
|
716
|
-
Combined with `Sql.Write.fetch_one`, the inserted row decodes into the
|
|
717
|
-
target struct:
|
|
707
|
+
A projection is a function of the columns, so it is written once and shared
|
|
708
|
+
between a SELECT and a RETURNING — both take the same `cols -> Select(a)`
|
|
709
|
+
shape:
|
|
718
710
|
|
|
719
711
|
```jade
|
|
720
|
-
def
|
|
721
|
-
|
|
722
|
-
select(Patient(_, _, _))
|
|
723
|
-
|> field(p.id)
|
|
724
|
-
|> field(p.name)
|
|
725
|
-
|> field(p.mrn)
|
|
726
|
-
})
|
|
727
|
-
|> fetch_one
|
|
712
|
+
def patient_row(p: PatientsCols) -> Select(Patient)
|
|
713
|
+
select(Patient(_, _, _)) |> field(p.id) |> field(p.name) |> field(p.mrn)
|
|
728
714
|
end
|
|
729
715
|
```
|
|
730
716
|
|
|
717
|
+
`from(patients) |> patient_row` for the read, `returning_with(patient_row)`
|
|
718
|
+
for the write. With row polymorphism a projection can span tables — one
|
|
719
|
+
`def just_id(c: { a | id: Expr(Int) })` serves every table with an `id`.
|
|
720
|
+
|
|
721
|
+
That is why there is no version that reads the columns off the result type's
|
|
722
|
+
field names. It would save the `field` lines, and it is the one thing in the
|
|
723
|
+
library that could name a column the table does not have — a projection built
|
|
724
|
+
from accessors cannot.
|
|
725
|
+
|
|
726
|
+
|
|
731
727
|
`filter` narrows a query or a write you have already built. Where
|
|
732
728
|
`where` takes a predicate, `filter` takes a *function* of the columns, so a
|
|
733
729
|
caller that has not bound them can still add one — enough to write a
|
data/docs/running.md
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
# Running queries and writes
|
|
2
2
|
|
|
3
|
-
`Sql.Query` and `Sql.Write
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
`fetch_one` / `fetch_many` live in `Sql.Query` and `Sql.Write`, because that
|
|
4
|
+
is where the row type is known — a `Select(Patient)` fetches a `Patient` and
|
|
5
|
+
nothing else. `Sql.execute` takes anything that renders, since a count says
|
|
6
|
+
nothing about the rows.
|
|
7
7
|
|
|
8
|
-
`Sql` keeps the `*_raw` siblings, which take a `(String, List(Value))`
|
|
9
|
-
and cannot know what they return:
|
|
8
|
+
`Sql` keeps the `*_raw` siblings too, which take a `(String, List(Value))`
|
|
9
|
+
pair and cannot know what they return:
|
|
10
10
|
|
|
11
11
|
```jade
|
|
12
|
-
import Sql exposing (SqlError, execute_raw)
|
|
12
|
+
import Sql exposing (SqlError, execute, execute_raw)
|
|
13
13
|
import Sql.Query exposing (fetch_many, fetch_one)
|
|
14
|
-
import Sql.Write exposing (execute)
|
|
15
14
|
|
|
16
15
|
# Affected count for INSERT/UPDATE/DELETE
|
|
17
16
|
def reschedule(a: Appointment) -> Task(Int, SqlError)
|
|
@@ -45,6 +44,21 @@ Sql.Write.fetch_one : Write(ret, c) -> Task(ret, SqlError)
|
|
|
45
44
|
A write only has a row type once `returning` gives it one, which is what
|
|
46
45
|
makes fetching from one meaningful.
|
|
47
46
|
|
|
47
|
+
Three reads have no row to decode, so they take a `Query` rather than a
|
|
48
|
+
`Select` and render their own select list over its clauses:
|
|
49
|
+
|
|
50
|
+
```jade
|
|
51
|
+
fetch_count : Query(c) -> Task(Int, e) # SELECT COUNT(*)
|
|
52
|
+
fetch_exists : Query(c) -> Task(Bool, e) # SELECT EXISTS (…)
|
|
53
|
+
fetch_values : Query(c), Expr(b) -> Task(List(b), e) # one column, every row
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`fetch_exists` stops at the first row Postgres finds rather than counting
|
|
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; two would
|
|
59
|
+
come back as a tuple, and a tuple of two columns of the same type is the
|
|
60
|
+
projection bug with no field names to catch it.
|
|
61
|
+
|
|
48
62
|
For raw SQL, skip the builders: `fetch_one_raw` / `fetch_many_raw` /
|
|
49
63
|
`execute_raw` take a `(String, List(Value))` pair. Their result type is
|
|
50
64
|
unconstrained, which is honest — nothing about a hand-written string says
|
|
@@ -56,17 +70,68 @@ returns plain Ruby hashes from AR, and they're decoded into typed structs
|
|
|
56
70
|
at the boundary.
|
|
57
71
|
|
|
58
72
|
`SqlError` variants:
|
|
59
|
-
- `DbError(String)` — AR `StatementInvalid` message
|
|
60
73
|
- `NotFound` — `fetch_one` with zero rows
|
|
61
74
|
- `TooManyRows` — `fetch_one` with more than one row
|
|
62
75
|
- `UniqueViolation(String)` — a write hit a unique index; the `String` is the
|
|
63
76
|
violated constraint name (e.g. `users_email_key`), so you can route it to a
|
|
64
77
|
field error instead of string-matching a `DbError` message
|
|
78
|
+
- `ForeignKeyViolation(String)`, `CheckViolation(String)` and
|
|
79
|
+
`ExclusionViolation(String)` — the same, for the other constraints
|
|
80
|
+
- `NotNullViolation(String)` — carries the column, since Postgres names no
|
|
81
|
+
constraint for one
|
|
82
|
+
- `Deadlock` and `SerializationFailure` — the transaction lost; the statement
|
|
83
|
+
is fine, and running it again is the usual answer
|
|
84
|
+
- `StatementTimeout` and `LockTimeout` — the statement ran out of time, or
|
|
85
|
+
waiting for a lock did
|
|
86
|
+
- `DbError(String)` — anything else, as the adapter's message
|
|
65
87
|
|
|
66
88
|
A decode mismatch (column type doesn't match the field type) raises on
|
|
67
89
|
the Ruby side rather than becoming a recoverable error — schema drift is
|
|
68
90
|
a programmer bug.
|
|
69
91
|
|
|
92
|
+
## Coming from ActiveRecord
|
|
93
|
+
|
|
94
|
+
There is no `find` or `find_by`. A lookup is the predicate you meant and the
|
|
95
|
+
runner that says how many rows you expect — and for anything an index covers,
|
|
96
|
+
`matching` builds that predicate from the generated index:
|
|
97
|
+
|
|
98
|
+
```jade
|
|
99
|
+
from(patients)
|
|
100
|
+
|> where(matching(patients_pkey, id))
|
|
101
|
+
|> selected
|
|
102
|
+
|> fetch_one
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Which is `find_by!` — no row is `NotFound`. A primary key is generated as a
|
|
106
|
+
unique index like any other, so a lookup by id has the same shape as one by
|
|
107
|
+
email. The key type comes from the index, so a composite cannot be given in
|
|
108
|
+
the wrong order, and renaming the index in the DDL breaks the call rather
|
|
109
|
+
than quietly matching nothing. A hand-written `where`/`filter` predicate is
|
|
110
|
+
for the columns no index covers.
|
|
111
|
+
|
|
112
|
+
The one to watch is **`fetch_at_most_one`, which is not `find_by`**: `find_by` is `LIMIT 1` and
|
|
113
|
+
returns the first row it happens to get, where this errors with
|
|
114
|
+
`TooManyRows`, because nothing is dropped to make the type fit. A query
|
|
115
|
+
ported across compiles, passes review, and then fails in production on the
|
|
116
|
+
first row that has a twin. If you wanted `LIMIT 1`, say `limit(1)`.
|
|
117
|
+
|
|
118
|
+
**Errors are values, not exceptions.** A read hands back
|
|
119
|
+
`Task(a, SqlError)`, and at the Ruby boundary `["ok", value]` or
|
|
120
|
+
`["err", encoded]`. `Sql.unwrap!` turns that into the value or raises the
|
|
121
|
+
variant, so one `rescue_from` routes a missing row the way
|
|
122
|
+
`ActiveRecord::RecordNotFound` does:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
# app/controllers/application_controller.rb
|
|
126
|
+
rescue_from Sql::Errors::NotFound, with: :not_found
|
|
127
|
+
|
|
128
|
+
# and at the call site, on whatever your module exposes
|
|
129
|
+
patient = Sql.unwrap!(Patients.by_id(params[:id]))
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The generated `fn!` raises too, but raises `Jade::Interop::TaskError` for
|
|
133
|
+
every failure alike, which a `rescue_from` cannot tell apart.
|
|
134
|
+
|
|
70
135
|
## Transactions
|
|
71
136
|
|
|
72
137
|
`Sql.transaction` runs a `Task` inside a single DB transaction on the
|
|
@@ -839,18 +839,65 @@ module JadeSql
|
|
|
839
839
|
end
|
|
840
840
|
end
|
|
841
841
|
|
|
842
|
+
# The codec is written rather than derived. A derived one reads the
|
|
843
|
+
# constructor and writes its snake_case, which is only the label when the
|
|
844
|
+
# label was lowercase to begin with — `'USD'` becomes the constructor
|
|
845
|
+
# `Usd` and would be stored as `usd`, which the column refuses. Writing it
|
|
846
|
+
# makes the DDL's label the thing that crosses.
|
|
842
847
|
def emit_enum_module(module_name, enum)
|
|
843
848
|
enum_type_name(enum.name).then do |type_name|
|
|
844
849
|
<<~JADE
|
|
845
850
|
module #{module_name}.#{camel(enum.name)} exposing (#{type_name}(..))
|
|
846
851
|
|
|
852
|
+
import Decode exposing (Decodable, Decoder, Value)
|
|
853
|
+
import Encode exposing (Encodable)
|
|
854
|
+
|
|
847
855
|
|
|
848
856
|
type #{type_name}
|
|
849
857
|
= #{variants_of(enum).join("\n | ")}
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
#{emit_enum_codec(type_name, enum)}
|
|
850
861
|
JADE
|
|
851
862
|
end
|
|
852
863
|
end
|
|
853
864
|
|
|
865
|
+
def emit_enum_codec(type_name, enum)
|
|
866
|
+
pairs = variants_of(enum).zip(enum.labels)
|
|
867
|
+
snake = snake_case(type_name)
|
|
868
|
+
|
|
869
|
+
[
|
|
870
|
+
"implements Encodable(#{type_name}) with",
|
|
871
|
+
" encoder: encode_#{snake}",
|
|
872
|
+
'end',
|
|
873
|
+
'',
|
|
874
|
+
'',
|
|
875
|
+
"def encode_#{snake}(v: #{type_name}) -> Value",
|
|
876
|
+
' case v',
|
|
877
|
+
*pairs.map { |v, l| " in #{v} then Encode.string(#{l.inspect})" },
|
|
878
|
+
' end',
|
|
879
|
+
'end',
|
|
880
|
+
'',
|
|
881
|
+
'',
|
|
882
|
+
"implements Decodable(#{type_name}) with",
|
|
883
|
+
" decoder: #{snake}_decoder",
|
|
884
|
+
'end',
|
|
885
|
+
'',
|
|
886
|
+
'',
|
|
887
|
+
"def #{snake}_decoder -> Decoder(#{type_name})",
|
|
888
|
+
" Decode.string |> Decode.and_then(#{snake}_of_label)",
|
|
889
|
+
'end',
|
|
890
|
+
'',
|
|
891
|
+
'',
|
|
892
|
+
"def #{snake}_of_label(s: String) -> Decoder(#{type_name})",
|
|
893
|
+
' case s',
|
|
894
|
+
*pairs.map { |v, l| " in #{l.inspect} then Decode.succeed(#{v})" },
|
|
895
|
+
%( else Decode.fail("not a #{enum.name}: " ++ s)),
|
|
896
|
+
' end',
|
|
897
|
+
'end',
|
|
898
|
+
].join("\n")
|
|
899
|
+
end
|
|
900
|
+
|
|
854
901
|
def enum_type_name(sql_name)
|
|
855
902
|
camel(sql_name)
|
|
856
903
|
end
|
data/lib/jade-sql/compiler.rb
CHANGED
|
@@ -27,8 +27,6 @@ end
|
|
|
27
27
|
require_relative 'compiler/errors'
|
|
28
28
|
require_relative 'compiler/assignable'
|
|
29
29
|
require_relative 'compiler/columns'
|
|
30
|
-
require_relative 'compiler/selectable'
|
|
31
30
|
|
|
32
31
|
Jade::Extensions.register_deriver('jade-sql', JadeSql::Compiler::Assignable)
|
|
33
|
-
Jade::Extensions.register_deriver('jade-sql', JadeSql::Compiler::Selectable)
|
|
34
32
|
Jade::Extensions.register_check('jade-sql', :call, JadeSql::Compiler::Columns)
|
data/lib/jade-sql/runtime.rb
CHANGED
|
@@ -11,35 +11,29 @@ module JadeSql
|
|
|
11
11
|
|
|
12
12
|
task :port_execute_count do |t, sql, params|
|
|
13
13
|
conn = ::ActiveRecord::Base.connection
|
|
14
|
-
t.ok(conn.exec_update(
|
|
15
|
-
rescue ::ActiveRecord::RecordNotUnique => e
|
|
16
|
-
t.err(JadeSql::SqlErrors.unique_violation(constraint_name(e)))
|
|
14
|
+
t.ok(conn.exec_update(statement(sql, params, conn), "Jade", typed_params(params, conn)))
|
|
17
15
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
18
|
-
t.err(
|
|
16
|
+
t.err(translate(e))
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
task :port_execute_one do |t, sql, params|
|
|
22
20
|
conn = ::ActiveRecord::Base.connection
|
|
23
|
-
rows = conn.exec_query(
|
|
21
|
+
rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).to_a
|
|
24
22
|
case rows.length
|
|
25
23
|
when 0 then t.err(JadeSql::SqlErrors.not_found)
|
|
26
24
|
when 1 then t.ok(coerce_row(rows.first))
|
|
27
25
|
else t.err(JadeSql::SqlErrors.too_many_rows)
|
|
28
26
|
end
|
|
29
|
-
rescue ::ActiveRecord::RecordNotUnique => e
|
|
30
|
-
t.err(JadeSql::SqlErrors.unique_violation(constraint_name(e)))
|
|
31
27
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
32
|
-
t.err(
|
|
28
|
+
t.err(translate(e))
|
|
33
29
|
end
|
|
34
30
|
|
|
35
31
|
task :port_execute_many do |t, sql, params|
|
|
36
32
|
conn = ::ActiveRecord::Base.connection
|
|
37
|
-
rows = conn.exec_query(
|
|
33
|
+
rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).to_a
|
|
38
34
|
t.ok(rows.map { |row| coerce_row(row) })
|
|
39
|
-
rescue ::ActiveRecord::RecordNotUnique => e
|
|
40
|
-
t.err(JadeSql::SqlErrors.unique_violation(constraint_name(e)))
|
|
41
35
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
42
|
-
t.err(
|
|
36
|
+
t.err(translate(e))
|
|
43
37
|
end
|
|
44
38
|
|
|
45
39
|
# Transaction control on the shared connection. The execute/fetch ports
|
|
@@ -53,7 +47,7 @@ module JadeSql
|
|
|
53
47
|
# Rollback is best-effort: it swallows adapter errors so the original
|
|
54
48
|
# failure is the one that propagates.
|
|
55
49
|
task :port_begin do |t|
|
|
56
|
-
::ActiveRecord::Base.connection.begin_transaction
|
|
50
|
+
::ActiveRecord::Base.connection.begin_transaction(joinable: false)
|
|
57
51
|
t.ok(true)
|
|
58
52
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
59
53
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
@@ -177,16 +171,51 @@ module JadeSql
|
|
|
177
171
|
raw == "NULL" ? nil : raw
|
|
178
172
|
end
|
|
179
173
|
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
174
|
+
# Read from Postgres rather than from ActiveRecord's classes for these,
|
|
175
|
+
# which lag it: `CheckViolation` arrived in Rails 8.
|
|
176
|
+
#
|
|
177
|
+
# https://www.postgresql.org/docs/current/errcodes-appendix.html
|
|
178
|
+
BY_SQLSTATE = {
|
|
179
|
+
'23505' => ->(e) { SqlErrors.unique_violation(constraint_name(e)) },
|
|
180
|
+
'23503' => ->(e) { SqlErrors.foreign_key_violation(constraint_name(e)) },
|
|
181
|
+
'23514' => ->(e) { SqlErrors.check_violation(constraint_name(e)) },
|
|
182
|
+
'23P01' => ->(e) { SqlErrors.exclusion_violation(constraint_name(e)) },
|
|
183
|
+
'23502' => ->(e) { SqlErrors.not_null_violation(column_name(e)) },
|
|
184
|
+
'40P01' => ->(_e) { SqlErrors.deadlock },
|
|
185
|
+
'40001' => ->(_e) { SqlErrors.serialization_failure },
|
|
186
|
+
'57014' => ->(_e) { SqlErrors.statement_timeout },
|
|
187
|
+
'55P03' => ->(_e) { SqlErrors.lock_timeout },
|
|
188
|
+
}.freeze
|
|
189
|
+
|
|
190
|
+
def self.translate(error)
|
|
191
|
+
BY_SQLSTATE[sqlstate(error)]
|
|
192
|
+
&.call(error) || SqlErrors.db_error(error.message)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Nil for anything that did not come back from Postgres.
|
|
196
|
+
def self.sqlstate(error)
|
|
197
|
+
diagnostic(error, ::PG::Result::PG_DIAG_SQLSTATE)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The constraint/index name behind a violation, so callers can route by
|
|
201
|
+
# which one it was. PG reports it in the error's diagnostics; other
|
|
202
|
+
# adapters (or a missing name) fall back to "".
|
|
183
203
|
def self.constraint_name(error)
|
|
204
|
+
diagnostic(error, ::PG::Result::PG_DIAG_CONSTRAINT_NAME) || ""
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.column_name(error)
|
|
208
|
+
diagnostic(error, ::PG::Result::PG_DIAG_COLUMN_NAME) || ""
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def self.diagnostic(error, field)
|
|
184
213
|
cause = error.cause
|
|
185
|
-
return
|
|
214
|
+
return nil unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
|
|
186
215
|
|
|
187
|
-
cause.result.error_field(
|
|
216
|
+
cause.result.error_field(field)
|
|
188
217
|
rescue StandardError
|
|
189
|
-
|
|
218
|
+
nil
|
|
190
219
|
end
|
|
191
220
|
|
|
192
221
|
# Sql.Write.timestamped emits "$JADE_SQL_NOW$" where created_at /
|
|
@@ -203,6 +232,23 @@ module JadeSql
|
|
|
203
232
|
sql.gsub(NOW_TOKEN) { stamp }
|
|
204
233
|
end
|
|
205
234
|
|
|
235
|
+
def self.statement(sql, params, conn)
|
|
236
|
+
fill_now(sql)
|
|
237
|
+
.tap { refuse_stacked(it) if params.empty? }
|
|
238
|
+
.then { adapt_sql(it, conn) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def self.refuse_stacked(sql)
|
|
242
|
+
at = sql.sub(/;\s*\z/, '').index(';')
|
|
243
|
+
return if at.nil?
|
|
244
|
+
|
|
245
|
+
raise ArgumentError,
|
|
246
|
+
"jade-sql refused a statement with a second one after `;` " \
|
|
247
|
+
"(at character #{at + 1}). Without bound values, Postgres would run " \
|
|
248
|
+
"every statement in the string. If a value holds the `;`, bind it " \
|
|
249
|
+
"with `?`. If you meant two statements, make two calls."
|
|
250
|
+
end
|
|
251
|
+
|
|
206
252
|
# Sql renders `?` placeholders uniformly. AR's exec_query/exec_update
|
|
207
253
|
# path on the PG adapter expects `$1, $2, …` — there is no `?`-to-`$n`
|
|
208
254
|
# rewrite at that layer. SQLite and MySQL accept `?` directly, so this
|
data/lib/jade-sql/sql/json.jd
CHANGED
|
@@ -11,7 +11,7 @@ module Sql.Json exposing (
|
|
|
11
11
|
fetch_one,
|
|
12
12
|
nested,
|
|
13
13
|
object,
|
|
14
|
-
|
|
14
|
+
from_array,
|
|
15
15
|
prop,
|
|
16
16
|
select,
|
|
17
17
|
text,
|
|
@@ -99,7 +99,7 @@ end
|
|
|
99
99
|
# Postgres arrays (`text[]`) are not JSON arrays: without this a `List(a)`
|
|
100
100
|
# column serializes as `{a,b}`. The type is unchanged — this is a rendering
|
|
101
101
|
# concern, not a different value.
|
|
102
|
-
def
|
|
102
|
+
def from_array(e: Expr(List(a))) -> Expr(List(a))
|
|
103
103
|
Expr("to_jsonb(" ++ e.sql ++ ")", e.params)
|
|
104
104
|
end
|
|
105
105
|
|
data/lib/jade-sql/sql/query.jd
CHANGED
|
@@ -9,9 +9,7 @@ module Sql.Query exposing (
|
|
|
9
9
|
group,
|
|
10
10
|
in_subquery,
|
|
11
11
|
having,
|
|
12
|
-
not_exists,
|
|
13
12
|
join,
|
|
14
|
-
rows,
|
|
15
13
|
left_join,
|
|
16
14
|
limit,
|
|
17
15
|
offset,
|
|
@@ -19,11 +17,11 @@ module Sql.Query exposing (
|
|
|
19
17
|
order_desc,
|
|
20
18
|
select,
|
|
21
19
|
fetch_at_most_one,
|
|
20
|
+
fetch_count,
|
|
21
|
+
fetch_exists,
|
|
22
22
|
fetch_many,
|
|
23
23
|
fetch_one,
|
|
24
|
-
|
|
25
|
-
fetch_rows,
|
|
26
|
-
selected,
|
|
24
|
+
fetch_values,
|
|
27
25
|
to_sql,
|
|
28
26
|
subquery,
|
|
29
27
|
where,
|
|
@@ -37,7 +35,7 @@ import Sql exposing (
|
|
|
37
35
|
Table,
|
|
38
36
|
TableRef(..),
|
|
39
37
|
ToSql,
|
|
40
|
-
|
|
38
|
+
from_sql_error,
|
|
41
39
|
)
|
|
42
40
|
import Decode exposing (Value)
|
|
43
41
|
|
|
@@ -92,7 +90,8 @@ struct Query(a) = {
|
|
|
92
90
|
limit_: Maybe(Int),
|
|
93
91
|
offset_: Maybe(Int),
|
|
94
92
|
distinct_: Bool,
|
|
95
|
-
result: a
|
|
93
|
+
result: a,
|
|
94
|
+
result_alias: Maybe(String)
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
|
|
@@ -115,6 +114,7 @@ def q_and_then(q: Query(a), fn: a -> Query(b)) -> Query(b)
|
|
|
115
114
|
merge_paging(q.offset_, next_.offset_),
|
|
116
115
|
q.distinct_ || next_.distinct_,
|
|
117
116
|
next_.result,
|
|
117
|
+
next_.result_alias,
|
|
118
118
|
)
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -147,7 +147,19 @@ end
|
|
|
147
147
|
def from(t: Table(c, m, k, o, r, s)) -> Query(c)
|
|
148
148
|
cols_ = t.alias_ |> t.cols
|
|
149
149
|
|
|
150
|
-
Query(
|
|
150
|
+
Query(
|
|
151
|
+
[TableRef(t.name, t.alias_)],
|
|
152
|
+
[],
|
|
153
|
+
[],
|
|
154
|
+
[],
|
|
155
|
+
[],
|
|
156
|
+
[],
|
|
157
|
+
Nothing,
|
|
158
|
+
Nothing,
|
|
159
|
+
False,
|
|
160
|
+
cols_,
|
|
161
|
+
Just(t.alias_),
|
|
162
|
+
)
|
|
151
163
|
end
|
|
152
164
|
|
|
153
165
|
|
|
@@ -166,6 +178,7 @@ def join(t: Table(c, m, k, o, r, s), on_: c -> Expr(Bool)) -> Query(c)
|
|
|
166
178
|
Nothing,
|
|
167
179
|
False,
|
|
168
180
|
cols_,
|
|
181
|
+
Just(t.alias_),
|
|
169
182
|
)
|
|
170
183
|
end
|
|
171
184
|
|
|
@@ -186,6 +199,7 @@ def left_join(t: Table(c, m, k, o, r, s), on_: c -> Expr(Bool)) -> Query(m)
|
|
|
186
199
|
Nothing,
|
|
187
200
|
False,
|
|
188
201
|
cols_maybe,
|
|
202
|
+
Just(t.alias_),
|
|
189
203
|
)
|
|
190
204
|
end
|
|
191
205
|
|
|
@@ -243,24 +257,7 @@ end
|
|
|
243
257
|
|
|
244
258
|
|
|
245
259
|
def select(make: a -> b) -> Query(Selector(a -> b))
|
|
246
|
-
Query([], [], [], [], [], [], Nothing, Nothing, False, Selector([], []))
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# `select`'s unprojected twin, for a query the caller carries on building
|
|
251
|
-
# rather than projects. It is what lets a subquery be written in a bind chain:
|
|
252
|
-
#
|
|
253
|
-
# v <- from(visits)
|
|
254
|
-
#
|
|
255
|
-
# rows(visits) |> where(...) |> order_desc(v.seen_on) |> limit(1)
|
|
256
|
-
#
|
|
257
|
-
# It names the table rather than the columns, so the query it starts stands on
|
|
258
|
-
# its own. A query that only borrowed columns would render without a `FROM`
|
|
259
|
-
# and read the outer query's table instead, which is legal SQL and a different
|
|
260
|
-
# question. Naming the same table the chain already bound costs nothing: a
|
|
261
|
-
# table is listed once however many times it is named.
|
|
262
|
-
def rows(t: Table(c, m, k, o, r, s)) -> Query(c)
|
|
263
|
-
from(t)
|
|
260
|
+
Query([], [], [], [], [], [], Nothing, Nothing, False, Selector([], []), Nothing)
|
|
264
261
|
end
|
|
265
262
|
|
|
266
263
|
|
|
@@ -276,6 +273,7 @@ def field(qs: Query(Selector(a -> b)), e: Expr(a)) -> Select(b)
|
|
|
276
273
|
qs.offset_,
|
|
277
274
|
qs.distinct_,
|
|
278
275
|
Selector(qs.result.columns_sql ++ [e.sql], qs.result.params ++ e.params),
|
|
276
|
+
qs.result_alias,
|
|
279
277
|
)
|
|
280
278
|
end
|
|
281
279
|
|
|
@@ -295,6 +293,7 @@ def field_as(qs: Query(Selector(a -> b)), e: Expr(a), name: String) -> Select(b)
|
|
|
295
293
|
qs.result.columns_sql ++ [e.sql ++ " AS " ++ name],
|
|
296
294
|
qs.result.params ++ e.params,
|
|
297
295
|
),
|
|
296
|
+
qs.result_alias,
|
|
298
297
|
)
|
|
299
298
|
end
|
|
300
299
|
|
|
@@ -364,11 +363,6 @@ def exists(q: Query(a)) -> Expr(Bool)
|
|
|
364
363
|
end
|
|
365
364
|
|
|
366
365
|
|
|
367
|
-
def not_exists(q: Query(a)) -> Expr(Bool)
|
|
368
|
-
wrap_subquery("NOT EXISTS (", q)
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
|
|
372
366
|
def wrap_subquery(keyword: String, q: Query(a)) -> Expr(Bool)
|
|
373
367
|
case render(q, ["1"], [])
|
|
374
368
|
in (sql, params) then Expr(keyword ++ sql ++ ")", params)
|
|
@@ -451,73 +445,103 @@ end
|
|
|
451
445
|
# Runs the query and decodes the single row it selected. Errors with
|
|
452
446
|
# `NotFound` on none and `TooManyRows` on more than one, so "exactly one" is
|
|
453
447
|
# checked rather than assumed.
|
|
454
|
-
def fetch_one(q: Select(a)) -> Task(a,
|
|
455
|
-
to_sql(q) |> Sql.fetch_one_raw
|
|
448
|
+
def fetch_one(q: Select(a)) -> Task(a, e)
|
|
449
|
+
to_sql(q) |> Sql.fetch_one_raw |> Task.map_error(from_sql_error)
|
|
456
450
|
end
|
|
457
451
|
|
|
458
452
|
|
|
459
453
|
# `fetch_one` where no row is an answer rather than an error. More than one
|
|
460
454
|
# is still `TooManyRows`: nothing is dropped to make the type fit.
|
|
461
|
-
def fetch_at_most_one(q: Select(a)) -> Task(Maybe(a),
|
|
462
|
-
q
|
|
463
|
-
|>
|
|
455
|
+
def fetch_at_most_one(q: Select(a)) -> Task(Maybe(a), e)
|
|
456
|
+
to_sql(q)
|
|
457
|
+
|> Sql.fetch_one_raw
|
|
464
458
|
|> Task.map(Just)
|
|
465
|
-
|> Task.on_error(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
459
|
+
|> Task.on_error(nothing_if_missing)
|
|
460
|
+
|> Task.map_error(from_sql_error)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# Before the widening, because whether a missing row is an error is a
|
|
465
|
+
# question about `SqlError` and the caller's type may not be able to say.
|
|
466
|
+
def nothing_if_missing(e: SqlError) -> Task(Maybe(a), SqlError)
|
|
467
|
+
case e
|
|
468
|
+
in NotFound then Task.succeed(Nothing)
|
|
469
|
+
else Task.fail(e)
|
|
470
|
+
end
|
|
471
471
|
end
|
|
472
472
|
|
|
473
473
|
|
|
474
474
|
# Runs the query and decodes every row it selected. No rows is an empty list,
|
|
475
475
|
# not an error.
|
|
476
|
-
def fetch_many(q: Select(a)) -> Task(List(a),
|
|
477
|
-
to_sql(q) |> Sql.fetch_many_raw
|
|
476
|
+
def fetch_many(q: Select(a)) -> Task(List(a), e)
|
|
477
|
+
to_sql(q) |> Sql.fetch_many_raw |> Task.map_error(from_sql_error)
|
|
478
478
|
end
|
|
479
479
|
|
|
480
480
|
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
def fetch_count(q: Query(a)) -> Task(Int, e)
|
|
497
|
+
render(q, ["COUNT(*) AS tally"], [])
|
|
498
|
+
|> Sql.fetch_one_raw
|
|
499
|
+
|> Task.map(count_of)
|
|
500
|
+
|> Task.map_error(from_sql_error)
|
|
485
501
|
end
|
|
486
502
|
|
|
487
503
|
|
|
488
|
-
def
|
|
489
|
-
|
|
504
|
+
def count_of(r: CountRow) -> Int
|
|
505
|
+
r.tally
|
|
490
506
|
end
|
|
491
507
|
|
|
492
508
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
509
|
+
# `SELECT EXISTS (...)`, which stops at the first row Postgres finds rather
|
|
510
|
+
# than counting every one of them. `exists` is the `Expr(Bool)` for a `WHERE`,
|
|
511
|
+
# where the keyword actually appears.
|
|
512
|
+
def fetch_exists(q: Query(a)) -> Task(Bool, e)
|
|
513
|
+
present = exists(q)
|
|
514
|
+
|
|
515
|
+
Tuple.Tuple2("SELECT " ++ present.sql ++ " AS present", present.params)
|
|
516
|
+
|> Sql.fetch_one_raw
|
|
517
|
+
|> Task.map(present_of)
|
|
518
|
+
|> Task.map_error(from_sql_error)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def present_of(r: PresentRow) -> Bool
|
|
523
|
+
r.present
|
|
506
524
|
end
|
|
507
525
|
|
|
508
526
|
|
|
509
|
-
#
|
|
510
|
-
#
|
|
511
|
-
# ambiguous one, but a name that exists in only one of the joined tables
|
|
512
|
-
# resolves to that one silently, whichever table the shape meant. Qualified,
|
|
513
|
-
# that case is `column patients.note does not exist` instead.
|
|
527
|
+
# One column out of every row, for the reads whose answer is a list of values
|
|
528
|
+
# rather than a list of rows:
|
|
514
529
|
#
|
|
515
|
-
#
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
530
|
+
# from(patients) |> where(p.archived |> eq(False)) |> fetch_values(p.id)
|
|
531
|
+
#
|
|
532
|
+
# Worth it only for a single column. Two would come back as a tuple, and a
|
|
533
|
+
# tuple of two columns of the same type is the projection bug with no field
|
|
534
|
+
# names to catch it — that is what `select |> field` is for.
|
|
535
|
+
def fetch_values(q: Query(c), e: Expr(b)) -> Task(List(b), err)
|
|
536
|
+
render(q, [e.sql ++ " AS value"], e.params)
|
|
537
|
+
|> Sql.fetch_many_raw
|
|
538
|
+
|> Task.map((rows) -> { List.map(rows, value_of) })
|
|
539
|
+
|> Task.map_error(from_sql_error)
|
|
540
|
+
end
|
|
519
541
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
542
|
+
|
|
543
|
+
def value_of(r: ValueRow(a)) -> a
|
|
544
|
+
r.value
|
|
523
545
|
end
|
|
546
|
+
|
|
547
|
+
|
data/lib/jade-sql/sql/write.jd
CHANGED
|
@@ -6,14 +6,13 @@ module Sql.Write exposing (
|
|
|
6
6
|
Write,
|
|
7
7
|
on_conflict,
|
|
8
8
|
delete,
|
|
9
|
-
execute,
|
|
10
9
|
fetch_many,
|
|
11
10
|
fetch_one,
|
|
12
11
|
delete_all,
|
|
13
12
|
insert,
|
|
14
13
|
insert_all,
|
|
15
14
|
Stamped,
|
|
16
|
-
|
|
15
|
+
returning_with,
|
|
17
16
|
timestamped,
|
|
18
17
|
to_sql,
|
|
19
18
|
update,
|
|
@@ -29,6 +28,7 @@ import Sql exposing (
|
|
|
29
28
|
ToSql,
|
|
30
29
|
Selector(..),
|
|
31
30
|
SqlError,
|
|
31
|
+
from_sql_error,
|
|
32
32
|
Pk,
|
|
33
33
|
Table,
|
|
34
34
|
Unique,
|
|
@@ -303,7 +303,7 @@ def on_conflict(m: Write(ret, c, s), u: Unique(c, uk), action: Action(s)) -> Wri
|
|
|
303
303
|
end
|
|
304
304
|
|
|
305
305
|
|
|
306
|
-
def
|
|
306
|
+
def returning_with(m: Write(a, c, s), build: c -> Query(Selector(b))) -> Write(b, c, s)
|
|
307
307
|
Write(
|
|
308
308
|
m.kind,
|
|
309
309
|
m.table,
|
|
@@ -603,23 +603,16 @@ implements ToSql(Write(ret, c, s)) with
|
|
|
603
603
|
end
|
|
604
604
|
|
|
605
605
|
|
|
606
|
-
# The number of rows the statement affected. Any `RETURNING` clause is
|
|
607
|
-
# rendered and discarded.
|
|
608
|
-
def execute(m: Write(ret, c, s)) -> Task(Int, SqlError)
|
|
609
|
-
to_sql(m) |> Sql.execute_raw
|
|
610
|
-
end
|
|
611
|
-
|
|
612
|
-
|
|
613
606
|
# Decodes the single row a `returning` write gives back — the id of an
|
|
614
607
|
# insert, the new state of an update. A write with no `returning` selects
|
|
615
608
|
# nothing, so this errors with `NotFound`.
|
|
616
|
-
def fetch_one(m: Write(ret, c, s)) -> Task(ret,
|
|
617
|
-
to_sql(m) |> Sql.fetch_one_raw
|
|
609
|
+
def fetch_one(m: Write(ret, c, s)) -> Task(ret, e)
|
|
610
|
+
to_sql(m) |> Sql.fetch_one_raw |> Task.map_error(from_sql_error)
|
|
618
611
|
end
|
|
619
612
|
|
|
620
613
|
|
|
621
614
|
# The same for a write that returns many rows, as `insert_all` and
|
|
622
615
|
# `update_all` do.
|
|
623
|
-
def fetch_many(m: Write(ret, c, s)) -> Task(List(ret),
|
|
624
|
-
to_sql(m) |> Sql.fetch_many_raw
|
|
616
|
+
def fetch_many(m: Write(ret, c, s)) -> Task(List(ret), e)
|
|
617
|
+
to_sql(m) |> Sql.fetch_many_raw |> Task.map_error(from_sql_error)
|
|
625
618
|
end
|
data/lib/jade-sql/sql.jd
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
module Sql exposing (
|
|
2
2
|
Assignable,
|
|
3
|
-
Selectable,
|
|
4
|
-
selector,
|
|
5
3
|
Assignment(..),
|
|
6
4
|
Col(..),
|
|
7
5
|
Expr(..),
|
|
@@ -11,6 +9,8 @@ module Sql exposing (
|
|
|
11
9
|
Pk,
|
|
12
10
|
Selector(..),
|
|
13
11
|
SqlError(..),
|
|
12
|
+
FromSqlError,
|
|
13
|
+
from_sql_error,
|
|
14
14
|
Table,
|
|
15
15
|
TableRef(..),
|
|
16
16
|
Unique,
|
|
@@ -263,11 +263,6 @@ end
|
|
|
263
263
|
|
|
264
264
|
|
|
265
265
|
|
|
266
|
-
interface Selectable(a) with
|
|
267
|
-
selector : Selector(a)
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
|
|
271
266
|
interface Assignable(a) with
|
|
272
267
|
to_assigns : a -> List(Assignment)
|
|
273
268
|
end
|
|
@@ -609,14 +604,42 @@ def aliased(t: Table(c, m, k, o, r, s), alias_: String) -> Table(c, m, k, o, r,
|
|
|
609
604
|
end
|
|
610
605
|
|
|
611
606
|
|
|
612
|
-
#
|
|
613
|
-
# a generated index rather than a literal: `i == users_email_key.name`
|
|
614
|
-
# compiling when the index is renamed, where a literal quietly stops
|
|
607
|
+
# A violated constraint carries the name Postgres reported. Compare it
|
|
608
|
+
# against a generated index rather than a literal: `i == users_email_key.name`
|
|
609
|
+
# stops compiling when the index is renamed, where a literal quietly stops
|
|
610
|
+
# matching. `NotNullViolation` carries the column, since Postgres names no
|
|
611
|
+
# constraint for one. `DbError` is what is left over.
|
|
615
612
|
type SqlError
|
|
616
613
|
= DbError(String)
|
|
617
614
|
| NotFound
|
|
618
615
|
| TooManyRows
|
|
619
616
|
| UniqueViolation(String)
|
|
617
|
+
| ForeignKeyViolation(String)
|
|
618
|
+
| CheckViolation(String)
|
|
619
|
+
| ExclusionViolation(String)
|
|
620
|
+
| NotNullViolation(String)
|
|
621
|
+
| Deadlock
|
|
622
|
+
| SerializationFailure
|
|
623
|
+
| StatementTimeout
|
|
624
|
+
| LockTimeout
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# The error a caller wants is rarely the error the database gives. An app
|
|
628
|
+
# with its own error type implements this once and the runners hand back
|
|
629
|
+
# that type instead, so a read is not followed by a line whose only job is
|
|
630
|
+
# to change the error's shape.
|
|
631
|
+
#
|
|
632
|
+
# Keyed on the type being produced, not the one being consumed: `SqlError`
|
|
633
|
+
# is named in the function, so the interface needs one parameter.
|
|
634
|
+
interface FromSqlError(e) with
|
|
635
|
+
from_sql_error : SqlError -> e
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# Without this every existing `Task(a, SqlError)` would stop resolving.
|
|
640
|
+
implements FromSqlError(SqlError) with
|
|
641
|
+
from_sql_error: identity
|
|
642
|
+
end
|
|
620
643
|
|
|
621
644
|
|
|
622
645
|
implements Encodable(SqlError) with
|
|
@@ -626,14 +649,27 @@ end
|
|
|
626
649
|
|
|
627
650
|
def encode_sql_error(e: SqlError) -> Value
|
|
628
651
|
case e
|
|
629
|
-
in DbError(msg) then
|
|
652
|
+
in DbError(msg) then named_variant("DbError", msg)
|
|
630
653
|
in NotFound then Encode.variant("NotFound", [])
|
|
631
654
|
in TooManyRows then Encode.variant("TooManyRows", [])
|
|
632
|
-
in UniqueViolation(name) then
|
|
655
|
+
in UniqueViolation(name) then named_variant("UniqueViolation", name)
|
|
656
|
+
in ForeignKeyViolation(name) then named_variant("ForeignKeyViolation", name)
|
|
657
|
+
in CheckViolation(name) then named_variant("CheckViolation", name)
|
|
658
|
+
in ExclusionViolation(name) then named_variant("ExclusionViolation", name)
|
|
659
|
+
in NotNullViolation(name) then named_variant("NotNullViolation", name)
|
|
660
|
+
in Deadlock then Encode.variant("Deadlock", [])
|
|
661
|
+
in SerializationFailure then Encode.variant("SerializationFailure", [])
|
|
662
|
+
in StatementTimeout then Encode.variant("StatementTimeout", [])
|
|
663
|
+
in LockTimeout then Encode.variant("LockTimeout", [])
|
|
633
664
|
end
|
|
634
665
|
end
|
|
635
666
|
|
|
636
667
|
|
|
668
|
+
def named_variant(tag: String, name: String) -> Value
|
|
669
|
+
Encode.variant(tag, [Encode.string(name)])
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
|
|
637
673
|
implements Decodable(SqlError) with
|
|
638
674
|
decoder: sql_error_decoder
|
|
639
675
|
end
|
|
@@ -641,20 +677,23 @@ end
|
|
|
641
677
|
|
|
642
678
|
def sql_error_decoder -> Decoder(SqlError)
|
|
643
679
|
Decode.type_
|
|
644
|
-
|> Decode.variant("DbError",
|
|
680
|
+
|> Decode.variant("DbError", named_decoder(DbError))
|
|
645
681
|
|> Decode.variant("NotFound", Decode.succeed(NotFound))
|
|
646
682
|
|> Decode.variant("TooManyRows", Decode.succeed(TooManyRows))
|
|
647
|
-
|> Decode.variant("UniqueViolation",
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
683
|
+
|> Decode.variant("UniqueViolation", named_decoder(UniqueViolation))
|
|
684
|
+
|> Decode.variant("ForeignKeyViolation", named_decoder(ForeignKeyViolation))
|
|
685
|
+
|> Decode.variant("CheckViolation", named_decoder(CheckViolation))
|
|
686
|
+
|> Decode.variant("ExclusionViolation", named_decoder(ExclusionViolation))
|
|
687
|
+
|> Decode.variant("NotNullViolation", named_decoder(NotNullViolation))
|
|
688
|
+
|> Decode.variant("Deadlock", Decode.succeed(Deadlock))
|
|
689
|
+
|> Decode.variant("SerializationFailure", Decode.succeed(SerializationFailure))
|
|
690
|
+
|> Decode.variant("StatementTimeout", Decode.succeed(StatementTimeout))
|
|
691
|
+
|> Decode.variant("LockTimeout", Decode.succeed(LockTimeout))
|
|
653
692
|
end
|
|
654
693
|
|
|
655
694
|
|
|
656
|
-
def
|
|
657
|
-
Decode.index(1, Decode.string) |> Decode.map(
|
|
695
|
+
def named_decoder(build: String -> SqlError) -> Decoder(SqlError)
|
|
696
|
+
Decode.index(1, Decode.string) |> Decode.map(build)
|
|
658
697
|
end
|
|
659
698
|
|
|
660
699
|
|
|
@@ -686,8 +725,8 @@ def fetch_many_raw(p: (String, List(Value))) -> Task(List(a), SqlError)
|
|
|
686
725
|
end
|
|
687
726
|
|
|
688
727
|
|
|
689
|
-
def execute(r: r) -> Task(Int,
|
|
690
|
-
to_sql(r) |> execute_raw
|
|
728
|
+
def execute(r: r) -> Task(Int, e)
|
|
729
|
+
to_sql(r) |> execute_raw |> Task.map_error(from_sql_error)
|
|
691
730
|
end
|
|
692
731
|
|
|
693
732
|
|
|
@@ -699,20 +738,29 @@ end
|
|
|
699
738
|
# every execute/fetch the task performs participates in it. Commits on
|
|
700
739
|
# Ok, rolls back and re-raises the error on Err. Nests as a savepoint of
|
|
701
740
|
# whichever transaction is already open, jade's or ActiveRecord's.
|
|
702
|
-
def transaction(task: Task(a,
|
|
741
|
+
def transaction(task: Task(a, e)) -> Task(a, e)
|
|
703
742
|
port_begin()
|
|
743
|
+
|> Task.map_error(from_sql_error)
|
|
704
744
|
|> Task.and_then((_) -> { commit_on_ok(task) })
|
|
705
745
|
end
|
|
706
746
|
|
|
707
747
|
|
|
708
|
-
def commit_on_ok(task: Task(a,
|
|
748
|
+
def commit_on_ok(task: Task(a, e)) -> Task(a, e)
|
|
709
749
|
task
|
|
710
|
-
|> Task.and_then(
|
|
750
|
+
|> Task.and_then(commit_returning)
|
|
711
751
|
|> Task.on_error(rollback_then_fail)
|
|
712
752
|
end
|
|
713
753
|
|
|
714
754
|
|
|
715
|
-
def
|
|
755
|
+
def commit_returning(value: a) -> Task(a, e)
|
|
756
|
+
port_commit()
|
|
757
|
+
|> Task.map_error(from_sql_error)
|
|
758
|
+
|> Task.map((_) -> { value })
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def rollback_then_fail(err: e) -> Task(a, e)
|
|
716
763
|
port_rollback()
|
|
764
|
+
|> Task.map_error(from_sql_error)
|
|
717
765
|
|> Task.and_then((_) -> { Task.fail(err) })
|
|
718
766
|
end
|
data/lib/jade-sql/version.rb
CHANGED
data/lib/jade-sql.rb
CHANGED
|
@@ -12,29 +12,92 @@ require_relative 'jade-sql/uuid_runtime'
|
|
|
12
12
|
# across the port boundary, and by anyone stubbing the ports in tests.
|
|
13
13
|
module JadeSql
|
|
14
14
|
module SqlErrors
|
|
15
|
-
NOT_FOUND
|
|
15
|
+
NOT_FOUND = ["NotFound"].freeze
|
|
16
16
|
TOO_MANY_ROWS = ["TooManyRows"].freeze
|
|
17
|
+
DEADLOCK = ["Deadlock"].freeze
|
|
18
|
+
SERIALIZATION_FAILURE = ["SerializationFailure"].freeze
|
|
19
|
+
STATEMENT_TIMEOUT = ["StatementTimeout"].freeze
|
|
20
|
+
LOCK_TIMEOUT = ["LockTimeout"].freeze
|
|
17
21
|
|
|
18
|
-
def self.db_error(msg)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
def self.db_error(msg)
|
|
23
|
+
["DbError", msg]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.not_found
|
|
27
|
+
NOT_FOUND
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.too_many_rows
|
|
31
|
+
TOO_MANY_ROWS
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.unique_violation(name)
|
|
35
|
+
["UniqueViolation", name]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.foreign_key_violation(name)
|
|
39
|
+
["ForeignKeyViolation", name]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.check_violation(name)
|
|
43
|
+
["CheckViolation", name]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.exclusion_violation(name)
|
|
47
|
+
["ExclusionViolation", name]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.not_null_violation(column)
|
|
51
|
+
["NotNullViolation", column]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.deadlock
|
|
55
|
+
DEADLOCK
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.serialization_failure
|
|
59
|
+
SERIALIZATION_FAILURE
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.statement_timeout
|
|
63
|
+
STATEMENT_TIMEOUT
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.lock_timeout
|
|
67
|
+
LOCK_TIMEOUT
|
|
68
|
+
end
|
|
22
69
|
end
|
|
23
70
|
end
|
|
24
71
|
|
|
25
72
|
module Sql
|
|
26
73
|
module Errors
|
|
27
74
|
class Error < StandardError; end
|
|
28
|
-
class DbError
|
|
29
|
-
class NotFound
|
|
75
|
+
class DbError < Error; end
|
|
76
|
+
class NotFound < Error; end
|
|
30
77
|
class TooManyRows < Error; end
|
|
31
|
-
class UniqueViolation
|
|
78
|
+
class UniqueViolation < Error; end
|
|
79
|
+
class ForeignKeyViolation < Error; end
|
|
80
|
+
class CheckViolation < Error; end
|
|
81
|
+
class ExclusionViolation < Error; end
|
|
82
|
+
class NotNullViolation < Error; end
|
|
83
|
+
class Deadlock < Error; end
|
|
84
|
+
class SerializationFailure < Error; end
|
|
85
|
+
class StatementTimeout < Error; end
|
|
86
|
+
class LockTimeout < Error; end
|
|
32
87
|
|
|
33
88
|
BY_TAG = {
|
|
34
|
-
"DbError"
|
|
35
|
-
"NotFound"
|
|
89
|
+
"DbError" => DbError,
|
|
90
|
+
"NotFound" => NotFound,
|
|
36
91
|
"TooManyRows" => TooManyRows,
|
|
37
|
-
"UniqueViolation"
|
|
92
|
+
"UniqueViolation" => UniqueViolation,
|
|
93
|
+
"ForeignKeyViolation" => ForeignKeyViolation,
|
|
94
|
+
"CheckViolation" => CheckViolation,
|
|
95
|
+
"ExclusionViolation" => ExclusionViolation,
|
|
96
|
+
"NotNullViolation" => NotNullViolation,
|
|
97
|
+
"Deadlock" => Deadlock,
|
|
98
|
+
"SerializationFailure" => SerializationFailure,
|
|
99
|
+
"StatementTimeout" => StatementTimeout,
|
|
100
|
+
"LockTimeout" => LockTimeout,
|
|
38
101
|
}.freeze
|
|
39
102
|
end
|
|
40
103
|
|
|
@@ -43,4 +106,18 @@ module Sql
|
|
|
43
106
|
klass = Errors::BY_TAG.fetch(type, Errors::Error)
|
|
44
107
|
raise klass, message
|
|
45
108
|
end
|
|
109
|
+
|
|
110
|
+
# The boundary hands back `["ok", value]` or `["err", encoded]`, and the
|
|
111
|
+
# generated `fn!` raises `Jade::Interop::TaskError` for every failure alike.
|
|
112
|
+
# A controller cannot route that: `rescue_from Sql::Errors::NotFound` needs
|
|
113
|
+
# the variant. This raises the variant instead, so one `rescue_from` turns a
|
|
114
|
+
# missing row into a 404 the way `ActiveRecord::RecordNotFound` does.
|
|
115
|
+
#
|
|
116
|
+
# patient = Sql.unwrap!(App.find(id))
|
|
117
|
+
def self.unwrap!(result)
|
|
118
|
+
case result
|
|
119
|
+
in ["ok", value] then value
|
|
120
|
+
in ["err", encoded] then raise_typed!(encoded)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
46
123
|
end
|
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.9.1
|
|
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-18 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: jade-lang
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.12.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.12.0
|
|
26
26
|
description: Query and write builders, schema generation from db/structure.sql, and
|
|
27
27
|
an ActiveRecord-backed runtime for the Jade language. Renders typed queries to (String,
|
|
28
28
|
List(Value)) and decodes rows into Jade structs.
|
|
@@ -44,7 +44,6 @@ files:
|
|
|
44
44
|
- lib/jade-sql/compiler/assignable.rb
|
|
45
45
|
- lib/jade-sql/compiler/columns.rb
|
|
46
46
|
- lib/jade-sql/compiler/errors.rb
|
|
47
|
-
- lib/jade-sql/compiler/selectable.rb
|
|
48
47
|
- lib/jade-sql/runtime.rb
|
|
49
48
|
- lib/jade-sql/schema_drift.rb
|
|
50
49
|
- lib/jade-sql/sql.jd
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
module JadeSql
|
|
2
|
-
module Compiler
|
|
3
|
-
# `Selectable(a)` is the read side of `Assignable(a)`: the fields of the
|
|
4
|
-
# shape you asked for become the columns selected, so a straightforward
|
|
5
|
-
# read needs no `select` at all.
|
|
6
|
-
#
|
|
7
|
-
# Only the names are derived. The values decode through the port's own
|
|
8
|
-
# `Decodable(a)`, which already reads a row by field name.
|
|
9
|
-
module Selectable
|
|
10
|
-
extend self
|
|
11
|
-
include Helpers
|
|
12
|
-
|
|
13
|
-
INTERFACE = 'Sql.Selectable'
|
|
14
|
-
SELECTOR = 'Sql.Selector'
|
|
15
|
-
|
|
16
|
-
def supports?(interface) = interface == INTERFACE
|
|
17
|
-
|
|
18
|
-
def derive(constraint, registry, entry_name, &_lookup)
|
|
19
|
-
case constraint.type
|
|
20
|
-
in Type::AnonymousRecord(fields:)
|
|
21
|
-
Ok[selectable(constraint, fields.keys)]
|
|
22
|
-
|
|
23
|
-
in Type::Application(constructor: Type::Constructor(name:), args:)
|
|
24
|
-
Symbol
|
|
25
|
-
.type_ref_from_qualified_name(name)
|
|
26
|
-
.then { registry.lookup(it) }
|
|
27
|
-
.then { derive_named(constraint, it, args, registry, entry_name) }
|
|
28
|
-
|
|
29
|
-
else
|
|
30
|
-
failed(constraint, entry_name)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def derive_named(constraint, symbol, args, registry, entry_name)
|
|
37
|
-
case symbol
|
|
38
|
-
in Symbol::Struct
|
|
39
|
-
struct_fields(symbol, args, registry)
|
|
40
|
-
.map(&:first)
|
|
41
|
-
.then { Ok[selectable(constraint, it)] }
|
|
42
|
-
|
|
43
|
-
else
|
|
44
|
-
failed(constraint, entry_name)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def selectable(constraint, names)
|
|
49
|
-
implementation(
|
|
50
|
-
constraint,
|
|
51
|
-
{ 'selector' => Symbol::DerivedFunction.new(params: [], body: body(names)) },
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# `Selector(columns, params)`: no params, because a column list carries
|
|
56
|
-
# no values to bind.
|
|
57
|
-
def body(names)
|
|
58
|
-
[:call,
|
|
59
|
-
[:struct_constructor, SELECTOR, 2],
|
|
60
|
-
[
|
|
61
|
-
[:list, names.map { Compiler.column_name(it) }],
|
|
62
|
-
[:list, []],
|
|
63
|
-
],
|
|
64
|
-
]
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def failed(constraint, entry_name)
|
|
68
|
-
Err[
|
|
69
|
-
DerivationFailed.new(
|
|
70
|
-
entry_name, constraint.origin&.range, constraint:, trace: [],
|
|
71
|
-
)
|
|
72
|
-
]
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|