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.
data/docs/running.md CHANGED
@@ -1,12 +1,16 @@
1
- # Running queries and mutations
1
+ # Running queries and writes
2
2
 
3
- `Sql` exposes `fetch_one` / `fetch_many` for reads and `execute` for
4
- writes all polymorphic over anything `Renderable` (Q, Mutation, …)
5
- plus `*_raw` siblings that take a `(String, List(Value))` pair for the
6
- escape hatch:
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, fetch_many, fetch_one)
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
- `fetch_one` / `fetch_many` / `execute` accept anything that implements
33
- `Sql.Renderable` (Q, Mutation). Internally they call `render(r) |> *_raw`,
34
- where `render` is the interface method that resolves to each container's
35
- `to_sql`. For raw SQL, skip the builder and call `fetch_one_raw` /
36
- `fetch_many_raw` / `execute_raw` directly with a `(String, List(Value))`
37
- pair.
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
- - `NotUnique` — `fetch_one` with more than one row
48
- - `Conflict(String)` — a write hit a unique index; the `String` is the
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 don't nest yet (no savepoints): wrapping a `transaction`
78
- inside another issues a second `BEGIN` on the same connection. Needs
79
- opt-in via `require 'jade-sql/runtime'`.
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
- case (command = ARGV.shift)
55
- in 'schema'
56
- run_schema(ARGV)
75
+ begin
76
+ case (command = ARGV.shift)
77
+ in 'schema'
78
+ run_schema(ARGV)
57
79
 
58
- in 'version' | '-v' | '--version'
59
- puts JadeSql::VERSION
80
+ in 'version' | '-v' | '--version'
81
+ puts JadeSql::VERSION
60
82
 
61
- in nil | 'help' | '-h' | '--help'
62
- puts HELP
83
+ in nil | 'help' | '-h' | '--help'
84
+ puts HELP
63
85
 
64
- else
65
- warn "jade-sql: unknown command '#{command}' (try `jade-sql help`)"
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