jade-sql 0.7.0 → 0.9.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 +411 -113
- data/docs/running.md +103 -18
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +702 -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.rb +32 -0
- data/lib/jade-sql/runtime.rb +74 -23
- 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 +309 -93
- data/lib/jade-sql/sql/write.jd +618 -0
- data/lib/jade-sql/sql.jd +389 -103
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +92 -14
- metadata +15 -8
- data/lib/jade-sql/sql/mutation.jd +0 -464
data/docs/running.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
# Running queries and
|
|
1
|
+
# Running queries and writes
|
|
2
2
|
|
|
3
|
-
`
|
|
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
|
+
|
|
8
|
+
`Sql` keeps the `*_raw` siblings too, which take a `(String, List(Value))`
|
|
9
|
+
pair and cannot know what they return:
|
|
7
10
|
|
|
8
11
|
```jade
|
|
9
|
-
import Sql exposing (SqlError, execute, execute_raw
|
|
12
|
+
import Sql exposing (SqlError, execute, execute_raw)
|
|
13
|
+
import Sql.Query exposing (fetch_many, fetch_one)
|
|
10
14
|
|
|
11
15
|
# Affected count for INSERT/UPDATE/DELETE
|
|
12
16
|
def reschedule(a: Appointment) -> Task(Int, SqlError)
|
|
@@ -29,12 +33,36 @@ def count_active -> Task(Int, SqlError)
|
|
|
29
33
|
end
|
|
30
34
|
```
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
Each runner is typed against what its module builds, so the row type the
|
|
37
|
+
query was written to produce is the one it hands back:
|
|
38
|
+
|
|
39
|
+
```jade
|
|
40
|
+
Sql.Query.fetch_one : Select(a) -> Task(a, SqlError)
|
|
41
|
+
Sql.Write.fetch_one : Write(ret, c) -> Task(ret, SqlError)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A write only has a row type once `returning` gives it one, which is what
|
|
45
|
+
makes fetching from one meaningful.
|
|
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
|
+
|
|
62
|
+
For raw SQL, skip the builders: `fetch_one_raw` / `fetch_many_raw` /
|
|
63
|
+
`execute_raw` take a `(String, List(Value))` pair. Their result type is
|
|
64
|
+
unconstrained, which is honest — nothing about a hand-written string says
|
|
65
|
+
what it returns.
|
|
38
66
|
|
|
39
67
|
Row decoding is automatic — the caller's type (`Patient`, `List(Patient)`)
|
|
40
68
|
threads its `Decodable` instance into the polymorphic port. The runtime
|
|
@@ -42,17 +70,68 @@ returns plain Ruby hashes from AR, and they're decoded into typed structs
|
|
|
42
70
|
at the boundary.
|
|
43
71
|
|
|
44
72
|
`SqlError` variants:
|
|
45
|
-
- `DbError(String)` — AR `StatementInvalid` message
|
|
46
73
|
- `NotFound` — `fetch_one` with zero rows
|
|
47
|
-
- `
|
|
48
|
-
- `
|
|
74
|
+
- `TooManyRows` — `fetch_one` with more than one row
|
|
75
|
+
- `UniqueViolation(String)` — a write hit a unique index; the `String` is the
|
|
49
76
|
violated constraint name (e.g. `users_email_key`), so you can route it to a
|
|
50
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
|
|
51
87
|
|
|
52
88
|
A decode mismatch (column type doesn't match the field type) raises on
|
|
53
89
|
the Ruby side rather than becoming a recoverable error — schema drift is
|
|
54
90
|
a programmer bug.
|
|
55
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
|
+
|
|
56
135
|
## Transactions
|
|
57
136
|
|
|
58
137
|
`Sql.transaction` runs a `Task` inside a single DB transaction on the
|
|
@@ -74,9 +153,15 @@ Because the wrapped task keeps its own decoding, `transaction` is fully
|
|
|
74
153
|
polymorphic in the result — `transaction(t) : Task(a, SqlError)` for any
|
|
75
154
|
`t : Task(a, SqlError)`.
|
|
76
155
|
|
|
77
|
-
Transactions
|
|
78
|
-
|
|
79
|
-
|
|
156
|
+
Transactions nest. A `transaction` inside another becomes a savepoint of
|
|
157
|
+
it, so an inner `Err` that the caller recovers from rolls back only the
|
|
158
|
+
inner work, while an outer `Err` still rolls back everything — including
|
|
159
|
+
what a nested transaction committed. The same holds in the other
|
|
160
|
+
direction: a jade transaction inside an `ActiveRecord::Base.transaction`
|
|
161
|
+
block is a savepoint of that block, and rolling the block back discards
|
|
162
|
+
the jade work with it.
|
|
163
|
+
|
|
164
|
+
Needs opt-in via `require 'jade-sql/runtime'`.
|
|
80
165
|
|
|
81
166
|
## Testing without a DB
|
|
82
167
|
|
data/exe/jade-sql
CHANGED
|
@@ -9,12 +9,14 @@ def run_schema(args)
|
|
|
9
9
|
require 'fileutils'
|
|
10
10
|
require 'jade-sql'
|
|
11
11
|
require 'jade-sql/bin/generate_schema'
|
|
12
|
+
require 'jade-sql/schema_drift'
|
|
12
13
|
|
|
13
14
|
opts = {
|
|
14
15
|
input: 'db/structure.sql',
|
|
15
16
|
output: 'app/jade/schema.jd',
|
|
16
17
|
module_name: 'Schema',
|
|
17
18
|
tables: nil,
|
|
19
|
+
check: false,
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
OptionParser.new do |o|
|
|
@@ -25,6 +27,7 @@ def run_schema(args)
|
|
|
25
27
|
opts[:tables] = it.split(',').map(&:strip).reject(&:empty?)
|
|
26
28
|
}
|
|
27
29
|
o.on('-m', '--module NAME', 'module name (default: Schema)') { opts[:module_name] = it }
|
|
30
|
+
o.on('-c', '--check', 'report drift and exit non-zero, writing nothing') { opts[:check] = true }
|
|
28
31
|
end.parse!(args)
|
|
29
32
|
|
|
30
33
|
generated = JadeSql::SchemaGenerator.generate(
|
|
@@ -33,11 +36,28 @@ def run_schema(args)
|
|
|
33
36
|
module_name: opts[:module_name],
|
|
34
37
|
)
|
|
35
38
|
|
|
39
|
+
return check(generated, opts[:output]) if opts[:check]
|
|
40
|
+
|
|
36
41
|
FileUtils.mkdir_p(File.dirname(opts[:output]))
|
|
37
42
|
File.write(opts[:output], generated)
|
|
38
43
|
puts "wrote #{opts[:output]}"
|
|
39
44
|
end
|
|
40
45
|
|
|
46
|
+
# For CI: the database moved, the schema did not, and every type built on
|
|
47
|
+
# it is now describing a table that is not there.
|
|
48
|
+
def check(generated, output)
|
|
49
|
+
unless File.exist?(output)
|
|
50
|
+
warn "#{output} does not exist. Generate it with `jade-sql schema`."
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
report = JadeSql::SchemaDrift.between(generated, File.read(output))
|
|
55
|
+
return puts("#{output} matches the database.") unless report.any?
|
|
56
|
+
|
|
57
|
+
warn report.to_s
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
41
61
|
HELP = <<~USAGE
|
|
42
62
|
jade-sql #{JadeSql::VERSION} — type-safe SQL for Jade
|
|
43
63
|
|
|
@@ -45,23 +65,31 @@ HELP = <<~USAGE
|
|
|
45
65
|
|
|
46
66
|
Commands:
|
|
47
67
|
schema Generate schema.jd from a SQL structure dump
|
|
68
|
+
(`--check` reports drift instead of writing)
|
|
48
69
|
version Print the version
|
|
49
70
|
help Show this message
|
|
50
71
|
|
|
51
72
|
Run `jade-sql schema --help` for schema options.
|
|
52
73
|
USAGE
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
begin
|
|
76
|
+
case (command = ARGV.shift)
|
|
77
|
+
in 'schema'
|
|
78
|
+
run_schema(ARGV)
|
|
57
79
|
|
|
58
|
-
in 'version' | '-v' | '--version'
|
|
59
|
-
|
|
80
|
+
in 'version' | '-v' | '--version'
|
|
81
|
+
puts JadeSql::VERSION
|
|
60
82
|
|
|
61
|
-
in nil | 'help' | '-h' | '--help'
|
|
62
|
-
|
|
83
|
+
in nil | 'help' | '-h' | '--help'
|
|
84
|
+
puts HELP
|
|
63
85
|
|
|
64
|
-
else
|
|
65
|
-
|
|
86
|
+
else
|
|
87
|
+
warn "jade-sql: unknown command '#{command}' (try `jade-sql help`)"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
rescue RuntimeError => e
|
|
91
|
+
# What the DDL holds that the generator cannot read. The message names it;
|
|
92
|
+
# a backtrace through the emitters would only bury that.
|
|
93
|
+
warn "jade-sql: #{e.message}"
|
|
66
94
|
exit 1
|
|
67
95
|
end
|