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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75f58977c75363b33bfd885e9059624c732f81b14ed764e8fc8f6ffc70842209
4
- data.tar.gz: 94ab9ce7fcc86ce8ac9e6868ffa8b0a7284af351baa1c1785db74921de419c56
3
+ metadata.gz: 1d437d0d6f0732ff240bc1833dd724ab0ddb17f11f048f64484971d4986c189a
4
+ data.tar.gz: 7570a5c8616b4281d64535e577dd9c70a504c285197ede6bda39e07b1061e397
5
5
  SHA512:
6
- metadata.gz: 3ea0a83ac2633a0096e848e0cf774f003772d6f1f166f72a525ba831d47b6ad2ef36b13d993004e551db9a18a3b43c99b004a04daf86fefe0bcaca689ab72014
7
- data.tar.gz: 3cf14a376b2195bca477c521820982e785b5669cab11ec3401e2f11ca03cbeeb6da5abe3489c58db198acf181d5dc0bf6c0014454b21f804923e18cfc74f2eb6
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)` and `not_exists(q)` ask whether a related row is there, without
285
- joining to it and without projecting anything from it. The inner query may
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` and `not_exists` ask whether a related row is there, `subquery` reads
408
- a single value out of one, and `in_subquery` matches a column against one a
409
- subquery selects. All four take an unprojected `Query`, which `rows` builds.
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 `not_exists` is the form to
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
- rows(visits)
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
- `rows(t)` is `select`'s unprojected twin: it starts a query that carries the
446
- table's columns rather than a projection, so a subquery is written in an
447
- ordinary bind chain. It names the table rather than borrowing columns, so the
448
- query it starts always renders its own `FROM` one that borrowed would read
449
- the outer query's table instead, which is legal SQL asking a different
450
- question. Naming a table the chain already bound costs nothing, since a table
451
- is listed once however many times it is named. The column is picked by a
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
- `returning` is the write-side counterpart to `select` for queries.
686
- It takes a closure that receives the table's column accessors and
687
- builds a Query-wrapped selector projecting them into a target type. The
688
- Query wrapper is just to share the same `select`/`field` builders as
689
- queries `returning` extracts the inner `Selector` and discards the
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, returning, to_sql)
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
- |> returning((p) -> {
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
- Bonus: the projector can be defined once and shared between SELECT
711
- queries and RETURNING — both contexts now take the same `cols ->
712
- Select(target)` shape, so a single `def patient_projector(p)`
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 create(np: NewPatient) -> Task(Patient, SqlError)
721
- np |> insert(patients) |> returning((p) -> {
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` each run what they build: `fetch_one` /
4
- `fetch_many` for reads, `execute` for writes. They live in the builder
5
- modules rather than in `Sql` because that is where the row type is known —
6
- a `Select(Patient)` fetches a `Patient` and nothing else.
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))` pair
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
@@ -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)
@@ -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(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)))
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(JadeSql::SqlErrors.db_error(e.message))
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(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
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(JadeSql::SqlErrors.db_error(e.message))
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(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
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(JadeSql::SqlErrors.db_error(e.message))
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
- # The constraint/index name behind a RecordNotUnique, so callers can route
181
- # by which unique index was violated. PG reports it in the error's
182
- # diagnostics; other adapters (or a missing name) fall back to "".
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 "" unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
214
+ return nil unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
186
215
 
187
- cause.result.error_field(::PG::Result::PG_DIAG_CONSTRAINT_NAME) || ""
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
@@ -11,7 +11,7 @@ module Sql.Json exposing (
11
11
  fetch_one,
12
12
  nested,
13
13
  object,
14
- of_array,
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 of_array(e: Expr(List(a))) -> Expr(List(a))
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
 
@@ -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
- fetch_row,
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
- selector,
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([TableRef(t.name, t.alias_)], [], [], [], [], [], Nothing, Nothing, False, cols_)
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, SqlError)
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), SqlError)
462
- q
463
- |> fetch_one
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((e) -> {
466
- case e
467
- in NotFound then Task.succeed(Nothing)
468
- else Task.fail(e)
469
- end
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), SqlError)
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
- # The shape you asked for names the columns, so a read with nothing computed
482
- # in it needs no `select`. `fetch_one` stays for a query that has one.
483
- def fetch_row(q: Query(c)) -> Task(a, SqlError)
484
- q |> selected |> fetch_one
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 fetch_rows(q: Query(c)) -> Task(List(a), SqlError)
489
- q |> selected |> fetch_many
504
+ def count_of(r: CountRow) -> Int
505
+ r.tally
490
506
  end
491
507
 
492
508
 
493
- def selected(q: Query(c)) -> Select(a)
494
- Query(
495
- q.tables,
496
- q.joins,
497
- q.wheres,
498
- q.groups,
499
- q.havings,
500
- q.orders,
501
- q.limit_,
502
- q.offset_,
503
- q.distinct_,
504
- qualified(q.tables, selector),
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
- # The shape names columns, not tables, so the alias comes from where the read
510
- # is rooted. Bare names would let Postgres choose: on a join it refuses an
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
- # A query with no table of its own is left alone; there is no alias to use.
516
- def qualified(tables: List(TableRef), s: Selector(a)) -> Selector(a)
517
- case tables
518
- in [] then s
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
- in [t | _]
521
- then Selector(List.map(s.columns_sql, (c) -> { t.alias_ ++ "." ++ c }), s.params)
522
- end
542
+
543
+ def value_of(r: ValueRow(a)) -> a
544
+ r.value
523
545
  end
546
+
547
+
@@ -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
- returning,
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 returning(m: Write(a, c, s), build: c -> Query(Selector(b))) -> Write(b, c, s)
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, SqlError)
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), SqlError)
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
- # `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.
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 Encode.variant("DbError", [Encode.string(msg)])
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 Encode.variant("UniqueViolation", [Encode.string(name)])
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", db_error_decoder)
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", unique_violation_decoder)
648
- end
649
-
650
-
651
- def db_error_decoder -> Decoder(SqlError)
652
- Decode.index(1, Decode.string) |> Decode.map(DbError)
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 unique_violation_decoder -> Decoder(SqlError)
657
- Decode.index(1, Decode.string) |> Decode.map(UniqueViolation)
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, SqlError)
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, SqlError)) -> Task(a, SqlError)
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, SqlError)) -> Task(a, SqlError)
748
+ def commit_on_ok(task: Task(a, e)) -> Task(a, e)
709
749
  task
710
- |> Task.and_then((value) -> { Task.map(port_commit(), (_) -> { value }) })
750
+ |> Task.and_then(commit_returning)
711
751
  |> Task.on_error(rollback_then_fail)
712
752
  end
713
753
 
714
754
 
715
- def rollback_then_fail(err: SqlError) -> Task(a, SqlError)
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
@@ -1,3 +1,3 @@
1
1
  module JadeSql
2
- VERSION = '0.8.0'
2
+ VERSION = '0.9.1'
3
3
  end
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 = ["NotFound"].freeze
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) = ["DbError", msg]
19
- def self.not_found = NOT_FOUND
20
- def self.too_many_rows = TOO_MANY_ROWS
21
- def self.unique_violation(name) = ["UniqueViolation", name]
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 < Error; end
29
- class NotFound < Error; end
75
+ class DbError < Error; end
76
+ class NotFound < Error; end
30
77
  class TooManyRows < Error; end
31
- class UniqueViolation < Error; end
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" => DbError,
35
- "NotFound" => NotFound,
89
+ "DbError" => DbError,
90
+ "NotFound" => NotFound,
36
91
  "TooManyRows" => TooManyRows,
37
- "UniqueViolation" => 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.8.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 00:00:00.000000000 Z
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.10.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.10.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