jade-sql 0.9.1 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d437d0d6f0732ff240bc1833dd724ab0ddb17f11f048f64484971d4986c189a
4
- data.tar.gz: 7570a5c8616b4281d64535e577dd9c70a504c285197ede6bda39e07b1061e397
3
+ metadata.gz: 4a375c4f1e9f8a4e724d4cfdb8019c47781be6fa151d63365dc217e0166d1cc3
4
+ data.tar.gz: cf7c46f4b57d31144dd38b15bf3471c437be7e0c3ae1315d1c5364de5a81f68b
5
5
  SHA512:
6
- metadata.gz: 88fd20a47981bd6735bee0d63fcb65d950c34da7b4deaa5f4d132f6d8342a2fe7c0f9db78628fbdfe043e001262e1751eec24528eef449234f0746076b791304
7
- data.tar.gz: aab1dbb9f6a251c3b61827bd605cb89ac81ca783f13f421db0093831c294556a863e3321e75e39531cc204a25693be8434a7caf3535b978b0712e100cc6454ff
6
+ metadata.gz: ef176c28d0a9d6dd4beb054940cc639ddf460bab7d05fc96cd9172b4d3f6841b855ea6b5e8aa4c66e1a83642a1167f412636dba1bf508d1a9c121e3708b50ae3
7
+ data.tar.gz: f54a7d82ec1d8c95441f5856a26298c6b8561dc4466b0ebab2820ac60b98fc7e2257c2d468016c5cb00021e262543f8183436970257d5ab07056b9658efa112e
data/docs/building.md CHANGED
@@ -56,10 +56,32 @@ type VisitStatus
56
56
  | Done
57
57
  ```
58
58
 
59
- Nullary unions derive `Encodable` and `Decodable` with the variant name in
60
- snake_case, which is the label Postgres stores — so the codec is free and
61
- `eq(v.status, "schedulled")` stops compiling. Before this, an enum
62
- column failed generation outright with `Unknown SQL type`.
59
+ The module carries a codec written from the labels, so `eq(v.status,
60
+ "schedulled")` stops compiling, and a `Finite` instance listing every variant.
61
+ That list is what lets `Expr.per_variant` give a `CASE` one arm per value:
62
+
63
+ ```jade
64
+ v.status
65
+ |> Expr.per_variant((s) -> {
66
+ case s
67
+ in Scheduled then val("upcoming")
68
+ in InProgress then val("now")
69
+ in Done then val("past")
70
+ end
71
+ })
72
+ # (CASE v.status WHEN ? THEN ? WHEN ? THEN ? WHEN ? THEN ? END)
73
+ ```
74
+
75
+ The arms come from a jade `case`, so a missing one is a compile error, and a
76
+ label added to the enum stops every `per_variant` that doesn't cover it
77
+ until it does. The branch runs once per value while the query is built, not
78
+ once per row. `Bool` has an instance too; anything else — a number, a string,
79
+ a nullable column — has no list of values to exhaust, so the compiler asks
80
+ for `case_of` instead: `No implementation of Sql.Expr.Finite for Int`.
81
+
82
+ A NULL scrutinee matches no arm and makes the whole `CASE` NULL. A nullable
83
+ column belongs in `case_when` with `is_null`, where that case is written
84
+ down.
63
85
 
64
86
  `bytea` isn't mapped yet, though jade's `Bytes` is the natural target. See
65
87
  jade-lang's `Decimal` for the full API (`of`/`scaled`/`parse`, arithmetic,
@@ -168,9 +190,8 @@ carries `NoJoins`.
168
190
 
169
191
  A column whose name is a Jade keyword (e.g. `type`) gets a trailing
170
192
  underscore in the struct field (`type_`) while the SQL column reference
171
- keeps the real name. For a table with such a column the generator also
172
- emits a `<table>_row` projector that aliases every column to its field name
173
- (`SELECT … AS type_`), so reads round-trip without hand-written SQL:
193
+ keeps the real name. The generator also emits a `<table>_row` projector
194
+ that reads every column into the table's `Row` struct:
174
195
 
175
196
  ```jade
176
197
  def entries -> Select(JournalEntriesRow)
@@ -179,10 +200,14 @@ def entries -> Select(JournalEntriesRow)
179
200
  end
180
201
  ```
181
202
 
182
- In hand-written selects, `field_as(e, "name")` sets a column's output name
183
- when it differs from the SQL — needed for renamed columns and computed
184
- projections (decode keys by field name, so `field_as(count_all, "visits")`
185
- makes a `COUNT(*)` land in a `visits` field).
203
+ A projection is read back by position: each `field` fills the constructor
204
+ argument in the same place, which is the order the type-checker matched
205
+ them in. A column's name never has to match its field, so a renamed column,
206
+ a computed one and two `id`s from a join all read the same way:
207
+
208
+ ```jade
209
+ select(Visits(_)) |> field(count_all) # lands in whatever Visits calls it
210
+ ```
186
211
 
187
212
  ## Build queries
188
213
 
@@ -380,8 +405,31 @@ builder — params stitch in declaration order automatically.
380
405
  | `sum(Expr(Int)) -> Expr(Maybe(Int))` | `SUM(e)` | `NULL` on empty group → `Maybe`. |
381
406
  | `count(Expr(a)) -> Expr(Int)` | `COUNT(e)` | Counts non-null rows for the column. |
382
407
  | `count_all -> Expr(Int)` | `COUNT(*)` | Total row count. |
408
+ | `count_distinct(Expr(a)) -> Expr(Int)` | `COUNT(DISTINCT e)` | Distinct non-null values. |
409
+ | `max`, `min(Expr(a)) -> Expr(Maybe(a))` | `MAX(e)`, `MIN(e)` | `NULL` on empty group. |
410
+ | `avg(Expr(Int)) -> Expr(Maybe(Decimal))` | `AVG(e)` | Postgres averages integers exactly. |
383
411
  | `coalesce(Expr(Maybe(a)), a) -> Expr(a)` | `COALESCE(e, ?)` | Drops the `Maybe` with a fallback. |
384
412
  | `neg(Expr(Int)) -> Expr(Int)` | `-(e)` | Unary minus. |
413
+ | `lower`, `upper(Expr(String)) -> Expr(String)` | `LOWER(e)` | |
414
+ | `trunc_date(DateUnit, Expr(Date)) -> Expr(Date)` | `date_trunc('month', e)` | First day of the unit. |
415
+ | `json_text(Expr(Value), String) -> Expr(Maybe(String))` | `e ->> ?` | `NULL` where the key is absent. |
416
+
417
+ `Sql.Expr` holds the ones that take a second expression rather than a value:
418
+
419
+ | Function | SQL |
420
+ |-------------------------------------------------|-----------------------------|
421
+ | `plus`, `minus`, `times`, `div` on `Expr(Int)` | `(a + b)`; `div` truncates |
422
+ | `concat(Expr(String), Expr(String))` | `(a \|\| b)` |
423
+ | `plus_days(Expr(Date), Expr(Int))` | `(a + b)` |
424
+ | `coalesce(Expr(Maybe(a)), Expr(a))` | `COALESCE(a, b)` |
425
+ | `greatest`, `least(Expr(a), Expr(a))` | `GREATEST(a, b)` |
426
+ | `filtered_to(Expr(a), Expr(Bool))` | `agg FILTER (WHERE cond)` |
427
+ | `case_when` / `when` / `otherwise` | `CASE WHEN … ELSE … END` |
428
+ | `per_variant(Expr(a), a -> Expr(b))` | `CASE e WHEN … END` |
429
+ | `case_of` / `when_eq` / `otherwise` | `CASE e WHEN … ELSE … END` |
430
+
431
+ A value you hold goes in through `val`, so each operation has one function:
432
+ `p.balance |> Expr.times(val(12))`.
385
433
 
386
434
  Worked example — count visits and the most recent visit number,
387
435
  coalesced to 0 when a patient has none:
@@ -399,8 +447,42 @@ end
399
447
  # SELECT COUNT(*), COALESCE(SUM(a.visit_no), ?) FROM appointments a
400
448
  ```
401
449
 
402
- For `CASE WHEN` and arithmetic, fall back to the raw-`Expr`
403
- escape hatch until they get a typed builder.
450
+ Three CASE spellings, by what the arms match on. Each builder takes its first
451
+ arm, and a `Case` is not an expression until `otherwise` closes it, so neither
452
+ a CASE without arms nor one without an `ELSE` can be written.
453
+
454
+ Conditions, where nothing can list every case:
455
+
456
+ ```jade
457
+ case_when(p.balance |> Expr.lt(val(0)), val("owes"))
458
+ |> Expr.when(p.balance |> Expr.gt(val(100)), val("credit"))
459
+ |> Expr.otherwise(val("even"))
460
+ ```
461
+
462
+ A value, where you care about some of what it can be:
463
+
464
+ ```jade
465
+ case_of(p.tier, val(1), val("gold"))
466
+ |> Expr.when_eq(val(2), val("silver"))
467
+ |> Expr.otherwise(val("basic"))
468
+ ```
469
+
470
+ Both sides of `when_eq` are expressions of the same shape, so a mapping
471
+ written the wrong way round is still a mapping — read those arms.
472
+
473
+ A value whose every case you want, which is `per_variant` and needs no
474
+ `ELSE` (see [enums](#generate-schemajd-from-dbstructuresql)). Adding an
475
+ `otherwise` to a CASE over an enum is legal and loses that check: a label
476
+ added to the enum lands quietly in the fallback, where `per_variant` would
477
+ have stopped compiling.
478
+
479
+ One total per condition is not a CASE. `SUM(CASE WHEN kind = 'income' THEN
480
+ amount ELSE 0 END)` is `filtered_to`, which says the same thing to Postgres
481
+ without the arms:
482
+
483
+ ```jade
484
+ sum(tl.amount_cents) |> Expr.filtered_to(eq(t.kind, Income))
485
+ ```
404
486
 
405
487
  ### Subqueries
406
488
 
@@ -662,8 +744,8 @@ values directly, so this is for the positions that cannot — a constant field
662
744
  in a projection or a JSON document:
663
745
 
664
746
  ```jade
665
- select(Row(_, _)) |> field(c.id) |> field_as(val("patient"), "kind")
666
- # SELECT patients.id, ? AS kind FROM patients patients
747
+ select(Row(_, _)) |> field(c.id) |> field(val("patient"))
748
+ # SELECT patients.id, ? FROM patients patients
667
749
  ```
668
750
 
669
751
  It binds rather than inlining, so a string with a quote in it is a parameter
@@ -680,7 +762,7 @@ a `SET` takes no alias.
680
762
 
681
763
  ### RETURNING
682
764
 
683
- `returning_with` names the columns coming back, from the table's accessors.
765
+ `returning` names the columns coming back, from the table's accessors.
684
766
  It is a step of its own rather than something `fetch_one` does for you: a
685
767
  write has one type whether you run it for a count or for a row, so folding the
686
768
  projection into the runner would mean `execute` and `fetch_one` producing
@@ -689,13 +771,13 @@ different SQL from the same `Write`.
689
771
  ```jade
690
772
  import Sql exposing(Selector)
691
773
  import Sql.Query exposing(select, field)
692
- import Sql.Write exposing(insert, returning_with, to_sql)
774
+ import Sql.Write exposing(insert, returning, to_sql)
693
775
 
694
776
  # INSERT INTO patients (name, mrn) VALUES (?, ?)
695
777
  # RETURNING patients.id, patients.name, patients.mrn
696
778
  np
697
779
  |> insert(patients)
698
- |> returning_with((p) -> {
780
+ |> returning((p) -> {
699
781
  select(Patient(_, _, _))
700
782
  |> field(p.id)
701
783
  |> field(p.name)
@@ -714,7 +796,7 @@ def patient_row(p: PatientsCols) -> Select(Patient)
714
796
  end
715
797
  ```
716
798
 
717
- `from(patients) |> patient_row` for the read, `returning_with(patient_row)`
799
+ `from(patients) |> patient_row` for the read, `returning(patient_row)`
718
800
  for the write. With row polymorphism a projection can span tables — one
719
801
  `def just_id(c: { a | id: Expr(Int) })` serves every table with an `id`.
720
802
 
data/docs/running.md CHANGED
@@ -55,19 +55,18 @@ fetch_values : Query(c), Expr(b) -> Task(List(b), e) # one column, every row
55
55
 
56
56
  `fetch_exists` stops at the first row Postgres finds rather than counting
57
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.
58
+ where the keyword appears in SQL. `fetch_values` reads one column; for more
59
+ than one, `select |> field` names the shape they land in.
61
60
 
62
61
  For raw SQL, skip the builders: `fetch_one_raw` / `fetch_many_raw` /
63
62
  `execute_raw` take a `(String, List(Value))` pair. Their result type is
64
63
  unconstrained, which is honest — nothing about a hand-written string says
65
64
  what it returns.
66
65
 
67
- Row decoding is automatic — the caller's type (`Patient`, `List(Patient)`)
68
- threads its `Decodable` instance into the polymorphic port. The runtime
69
- returns plain Ruby hashes from AR, and they're decoded into typed structs
70
- at the boundary.
66
+ A projection's rows come back as arrays, and each column goes through its
67
+ field type's `Decodable` in the position `field` put it. The `*_raw` runners
68
+ have no projection, so they decode by column name into the result type
69
+ instead.
71
70
 
72
71
  `SqlError` variants:
73
72
  - `NotFound` — `fetch_one` with zero rows
@@ -174,10 +173,8 @@ describe MyApp do
174
173
  include Jade::Tasks::RSpec
175
174
 
176
175
  it 'queries patients' do
177
- all_calls_to(JadeSql::Runtime.port_execute_many) do |t, _sql, _params|
178
- t.ok([
179
- { "id" => 1, "name" => "Paul", "mrn" => "MRN-001" }
180
- ])
176
+ all_calls_to(JadeSql::Runtime.port_execute_rows) do |t, _sql, _params|
177
+ t.ok([[1, "Paul", "MRN-001"]])
181
178
  end
182
179
 
183
180
  expect(MyApp.list.run).to be_ok
@@ -185,7 +182,9 @@ describe MyApp do
185
182
  end
186
183
  ```
187
184
 
188
- The three ports are `port_execute_count`, `port_execute_one`,
189
- `port_execute_many` — `Sql.execute*` / `Sql.fetch_*` and their `*_raw`
190
- siblings ultimately dispatch through these. Stub them with
185
+ Everything dispatches through four ports. `port_execute_rows` runs the
186
+ builders' reads and RETURNING writes and answers with one array per row, in
187
+ `field` order. `port_execute_count` runs `execute`, and `port_execute_one` /
188
+ `port_execute_many` run the `*_raw` reads, answering with hashes keyed by
189
+ column name. Stub them with
191
190
  `all_calls_to(JadeSql::Runtime.port_execute_*) { |t, sql, params| ... }`.
@@ -503,7 +503,7 @@ module JadeSql
503
503
  ].sort
504
504
  sql_import = "import Sql exposing(#{(types + fns).join(', ')})"
505
505
  enum_imports = enum_imports_for(tables, module_name)
506
- query_import = ["import Sql.Query exposing(Select, field_as, select)"]
506
+ query_import = ["import Sql.Query exposing(Select, field, select)"]
507
507
  # A join predicate compares two columns, which is `Sql.Expr`'s side of
508
508
  # the operator split rather than `Sql`'s value-taking one.
509
509
  expr_import = joined.any? ? ["import Sql.Expr as Expr"] : []
@@ -793,17 +793,14 @@ module JadeSql
793
793
  JADE
794
794
  end
795
795
 
796
- # A row projector that aliases every column to its (possibly renamed)
797
- # field name, so a reserved-word column like `type` round-trips through
798
- # decode: `SELECT alias.type AS type_`. Emitted only for tables that have
799
- # a renamed column. Composes in a bind-chain:
796
+ # Every column of the table into its Row struct. Composes in a bind-chain:
800
797
  # c <- from(t)
801
798
  # t_row(c) |> where(...)
802
799
  def emit_row_projector(t)
803
800
  klass = camel(t.name)
804
801
  holes = t.columns.map { "_" }.join(", ")
805
802
  projections = t.columns
806
- .map { |c| " |> field_as(c.#{field_name(c.name)}, #{field_name(c.name).inspect})" }
803
+ .map { |c| " |> field(c.#{field_name(c.name)})" }
807
804
  .join("\n")
808
805
 
809
806
  <<~JADE.strip
@@ -851,6 +848,7 @@ module JadeSql
851
848
 
852
849
  import Decode exposing (Decodable, Decoder, Value)
853
850
  import Encode exposing (Encodable)
851
+ import Sql.Expr exposing (Finite)
854
852
 
855
853
 
856
854
  type #{type_name}
@@ -895,6 +893,16 @@ module JadeSql
895
893
  %( else Decode.fail("not a #{enum.name}: " ++ s)),
896
894
  ' end',
897
895
  'end',
896
+ '',
897
+ '',
898
+ "implements Finite(#{type_name}) with",
899
+ " variants: #{snake}_variants",
900
+ 'end',
901
+ '',
902
+ '',
903
+ "def #{snake}_variants -> List(#{type_name})",
904
+ " [#{variants_of(enum).join(', ')}]",
905
+ 'end',
898
906
  ].join("\n")
899
907
  end
900
908
 
@@ -36,6 +36,14 @@ module JadeSql
36
36
  t.err(translate(e))
37
37
  end
38
38
 
39
+ task :port_execute_rows do |t, sql, params|
40
+ conn = ::ActiveRecord::Base.connection
41
+ rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).rows
42
+ t.ok(rows.map { |row| row.map { coerce_value(it) } })
43
+ rescue ::ActiveRecord::StatementInvalid => e
44
+ t.err(translate(e))
45
+ end
46
+
39
47
  # Transaction control on the shared connection. The execute/fetch ports
40
48
  # above use the same `ActiveRecord::Base.connection`, so anything they
41
49
  # run between begin and commit/rollback is part of this transaction.
@@ -1,15 +1,38 @@
1
1
  module Sql.Expr exposing (
2
+ Case,
3
+ Finite,
4
+ Matching,
5
+ Searched,
6
+ case_of,
7
+ case_when,
8
+ coalesce,
9
+ concat,
10
+ div,
2
11
  eq,
12
+ filtered_to,
13
+ greatest,
3
14
  gt,
4
15
  gte,
5
16
  ilike,
17
+ least,
6
18
  like,
7
19
  lt,
8
20
  lte,
21
+ minus,
9
22
  neq,
23
+ otherwise,
24
+ per_variant,
25
+ plus,
26
+ plus_days,
27
+ times,
28
+ when,
29
+ when_eq,
10
30
  )
11
31
 
12
32
  import Sql exposing (Expr(..))
33
+ import Encode exposing (Encodable, encode)
34
+ import Decode exposing (Value)
35
+ import Calendar exposing (Date)
13
36
 
14
37
 
15
38
  # The same comparisons `Sql` exposes, against another expression rather than a
@@ -63,3 +86,198 @@ end
63
86
  def binary(left: Expr(a), op: String, right: Expr(b)) -> Expr(Bool)
64
87
  Expr(left.sql ++ op ++ right.sql, left.params ++ right.params)
65
88
  end
89
+
90
+
91
+ # A type whose every value is known: a database enum, whose instance the schema
92
+ # generator writes next to its codec, and `Bool`.
93
+ # A type with finitely many values, all of them listed. The schema generator
94
+ # writes an instance beside each database enum's codec, so the list is
95
+ # regenerated with the type. Writing one by hand is the one way to make
96
+ # `per_variant` render a CASE that misses a value.
97
+ interface Finite(a) with
98
+ variants: List(a)
99
+ end
100
+
101
+
102
+ implements Finite(Bool) with
103
+ variants: bool_variants
104
+ end
105
+
106
+
107
+ def bool_variants -> List(Bool)
108
+ [True, False]
109
+ end
110
+
111
+
112
+ # A `CASE` with one arm per value of `e`, each arm what `branch` makes of it.
113
+ # `branch` is an ordinary function, so the `case` inside it is checked for
114
+ # every value, and the SQL needs no `ELSE`:
115
+ #
116
+ # t.kind |> Expr.per_variant((k) -> {
117
+ # case k
118
+ # in Income then tl.amount_cents
119
+ # in Expense then neg(tl.amount_cents)
120
+ # end
121
+ # })
122
+ # # (CASE t.kind WHEN ? THEN tl.amount_cents WHEN ? THEN -(tl.amount_cents) END)
123
+ #
124
+ # `per_variant` is finished the moment you call it — the arms are the
125
+ # type's, so there is no `otherwise` to mark the end.
126
+ #
127
+ # `branch` runs once per value while the query is built, not once per row. A
128
+ # NULL scrutinee matches no arm and the CASE is NULL, so a nullable column
129
+ # belongs in `case_when` with `is_null` rather than here.
130
+ def per_variant(e: Expr(a), branch: a -> Expr(b)) -> Expr(b)
131
+ arms = variants |> List.map((v) -> { (encode(v), branch(v)) })
132
+ whens = arms
133
+ |> List.map((arm) -> { " WHEN ? THEN " ++ Tuple.second(arm).sql })
134
+ |> String.concat
135
+ params = arms
136
+ |> List.and_then((arm) -> { [Tuple.first(arm)] ++ Tuple.second(arm).params })
137
+
138
+ Expr("(CASE " ++ e.sql ++ whens ++ " END)", e.params ++ params)
139
+ end
140
+
141
+
142
+ # Arithmetic on integers. Postgres divides integers by truncating toward
143
+ # zero, and raises on a zero divisor.
144
+ def plus(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
145
+ operator(left, " + ", right)
146
+ end
147
+
148
+
149
+ def minus(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
150
+ operator(left, " - ", right)
151
+ end
152
+
153
+
154
+ def times(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
155
+ operator(left, " * ", right)
156
+ end
157
+
158
+
159
+ def div(left: Expr(Int), right: Expr(Int)) -> Expr(Int)
160
+ operator(left, " / ", right)
161
+ end
162
+
163
+
164
+ def concat(left: Expr(String), right: Expr(String)) -> Expr(String)
165
+ operator(left, " || ", right)
166
+ end
167
+
168
+
169
+ # Typed on both sides, since `date + integer` is one of several `+`s and a
170
+ # bound value names neither.
171
+ def plus_days(d: Expr(Date), days: Expr(Int)) -> Expr(Date)
172
+ Expr(
173
+ "((" ++ d.sql ++ ")::date + (" ++ days.sql ++ ")::integer)",
174
+ d.params ++ days.params,
175
+ )
176
+ end
177
+
178
+
179
+ def operator(left: Expr(a), op: String, right: Expr(b)) -> Expr(c)
180
+ Expr("(" ++ left.sql ++ op ++ right.sql ++ ")", left.params ++ right.params)
181
+ end
182
+
183
+
184
+ # `COALESCE` against an expression: another column, a subquery. `Sql.coalesce`
185
+ # takes a value you hold.
186
+ def coalesce(e: Expr(Maybe(a)), fallback: Expr(a)) -> Expr(a)
187
+ Expr("COALESCE(" ++ e.sql ++ ", " ++ fallback.sql ++ ")", e.params ++ fallback.params)
188
+ end
189
+
190
+
191
+ def greatest(left: Expr(a), right: Expr(a)) -> Expr(a)
192
+ Expr("GREATEST(" ++ left.sql ++ ", " ++ right.sql ++ ")", left.params ++ right.params)
193
+ end
194
+
195
+
196
+ def least(left: Expr(a), right: Expr(a)) -> Expr(a)
197
+ Expr("LEAST(" ++ left.sql ++ ", " ++ right.sql ++ ")", left.params ++ right.params)
198
+ end
199
+
200
+
201
+ # An aggregate over only the rows `condition` keeps:
202
+ #
203
+ # sum(tl.amount_cents) |> Expr.filtered_to(eq(t.kind, Income))
204
+ # # SUM(tl.amount_cents) FILTER (WHERE t.kind = ?)
205
+ def filtered_to(aggregate: Expr(a), condition: Expr(Bool)) -> Expr(a)
206
+ Expr(
207
+ aggregate.sql ++ " FILTER (WHERE " ++ condition.sql ++ ")",
208
+ aggregate.params ++ condition.params,
209
+ )
210
+ end
211
+
212
+
213
+ # A `CASE` you close yourself, in the two shapes Postgres writes. `t` says
214
+ # which shape — `Searched` for `case_when`, `Matching(a)` for a `case_of` over
215
+ # an `Expr(a)` — so an arm can only be added by the builder it belongs to, and
216
+ # `b` is what the arms produce. A `Case` is not an expression
217
+ # until `otherwise` gives it an `ELSE`, and each builder takes its first arm,
218
+ # so a `CASE` with no arms cannot be written:
219
+ #
220
+ # case_when(p.age |> Expr.lt(val(18)), val("minor"))
221
+ # |> Expr.when(p.age |> Expr.gte(val(65)), val("senior"))
222
+ # |> Expr.otherwise(val("adult"))
223
+ # # (CASE WHEN p.age < ? THEN ? WHEN p.age >= ? THEN ? ELSE ? END)
224
+ #
225
+ # case_of(p.tier, val(1), val("gold"))
226
+ # |> Expr.when_eq(val(2), val("silver"))
227
+ # |> Expr.otherwise(val("basic"))
228
+ # # (CASE p.tier WHEN ? THEN ? WHEN ? THEN ? ELSE ? END)
229
+ #
230
+ # Over a type whose values are all known, `per_variant` covers them without
231
+ # an `ELSE`, and the compiler checks the arms. Reach for `case_of` when the
232
+ # values are not, and read the arms carefully: both sides of `when_eq` are
233
+ # expressions of the same shape, so a mapping written the wrong way round is
234
+ # still a mapping.
235
+ type Searched = SearchedOnly
236
+
237
+
238
+ type Matching(a) = MatchingOnly
239
+
240
+
241
+ struct Case(t, b) = {
242
+ sql: String,
243
+ params: List(Value)
244
+ }
245
+
246
+
247
+ def case_when(condition: Expr(Bool), result: Expr(b)) -> Case(Searched, b)
248
+ Case(
249
+ "CASE WHEN " ++ condition.sql ++ " THEN " ++ result.sql,
250
+ condition.params ++ result.params,
251
+ )
252
+ end
253
+
254
+
255
+ def when(c: Case(Searched, b), condition: Expr(Bool), result: Expr(b)) -> Case(Searched, b)
256
+ with_arm(c, condition, result)
257
+ end
258
+
259
+
260
+ def case_of(e: Expr(a), value: Expr(a), result: Expr(b)) -> Case(Matching(a), b)
261
+ Case(
262
+ "CASE " ++ e.sql ++ " WHEN " ++ value.sql ++ " THEN " ++ result.sql,
263
+ e.params ++ value.params ++ result.params,
264
+ )
265
+ end
266
+
267
+
268
+ def when_eq(c: Case(Matching(a), b), value: Expr(a), result: Expr(b)) -> Case(Matching(a), b)
269
+ with_arm(c, value, result)
270
+ end
271
+
272
+
273
+ def with_arm(c: Case(t, b), matched: Expr(a), result: Expr(b)) -> Case(t, b)
274
+ Case(
275
+ c.sql ++ " WHEN " ++ matched.sql ++ " THEN " ++ result.sql,
276
+ c.params ++ matched.params ++ result.params,
277
+ )
278
+ end
279
+
280
+
281
+ def otherwise(c: Case(t, b), fallback: Expr(b)) -> Expr(b)
282
+ Expr("(" ++ c.sql ++ " ELSE " ++ fallback.sql ++ " END)", c.params ++ fallback.params)
283
+ end
@@ -128,12 +128,11 @@ def text(d: Doc(a)) -> String
128
128
  end
129
129
 
130
130
 
131
- # The row comes back as a hash keyed by column name, so the projection needs
132
- # an alias matching Doc's field. `cast` is the escape hatch that erases
133
- # Json(a) to the String the column actually holds — it stays inside this
134
- # module, and the signature ties the result's tag back to the projection's.
131
+ # `unsafe_cast` erases Json(a) to the String the column actually holds — it
132
+ # stays inside this module, and the signature ties the result's tag back to
133
+ # the projection's.
135
134
  def select(e: Expr(Json(a))) -> Query(Selector(Doc(a)))
136
- Query.select(Doc(_)) |> Query.field_as(unsafe_cast(e), "text")
135
+ Query.select(Doc(_)) |> Query.field(unsafe_cast(e))
137
136
  end
138
137
 
139
138
 
@@ -2,7 +2,6 @@ module Sql.Query exposing (
2
2
  Query,
3
3
  Select,
4
4
  field,
5
- field_as,
6
5
  distinct,
7
6
  exists,
8
7
  from,
@@ -37,7 +36,7 @@ import Sql exposing (
37
36
  ToSql,
38
37
  from_sql_error,
39
38
  )
40
- import Decode exposing (Value)
39
+ import Decode exposing (Decodable, Decoder, Value)
41
40
 
42
41
 
43
42
  type JoinKind
@@ -257,28 +256,25 @@ end
257
256
 
258
257
 
259
258
  def select(make: a -> b) -> Query(Selector(a -> b))
260
- Query([], [], [], [], [], [], Nothing, Nothing, False, Selector([], []), Nothing)
261
- end
262
-
263
-
264
- def field(qs: Query(Selector(a -> b)), e: Expr(a)) -> Select(b)
265
259
  Query(
266
- qs.tables,
267
- qs.joins,
268
- qs.wheres,
269
- qs.groups,
270
- qs.havings,
271
- qs.orders,
272
- qs.limit_,
273
- qs.offset_,
274
- qs.distinct_,
275
- Selector(qs.result.columns_sql ++ [e.sql], qs.result.params ++ e.params),
276
- qs.result_alias,
260
+ [],
261
+ [],
262
+ [],
263
+ [],
264
+ [],
265
+ [],
266
+ Nothing,
267
+ Nothing,
268
+ False,
269
+ Selector([], [], Decode.succeed(make)),
270
+ Nothing,
277
271
  )
278
272
  end
279
273
 
280
274
 
281
- def field_as(qs: Query(Selector(a -> b)), e: Expr(a), name: String) -> Select(b)
275
+ def field(qs: Query(Selector(a -> b)), e: Expr(a)) -> Select(b)
276
+ position = List.length(qs.result.columns_sql)
277
+
282
278
  Query(
283
279
  qs.tables,
284
280
  qs.joins,
@@ -290,8 +286,9 @@ def field_as(qs: Query(Selector(a -> b)), e: Expr(a), name: String) -> Select(b)
290
286
  qs.offset_,
291
287
  qs.distinct_,
292
288
  Selector(
293
- qs.result.columns_sql ++ [e.sql ++ " AS " ++ name],
289
+ qs.result.columns_sql ++ [e.sql],
294
290
  qs.result.params ++ e.params,
291
+ qs.result.decoder |> Decode.and_map(Decode.index(position, Decode.decoder)),
295
292
  ),
296
293
  qs.result_alias,
297
294
  )
@@ -446,15 +443,17 @@ end
446
443
  # `NotFound` on none and `TooManyRows` on more than one, so "exactly one" is
447
444
  # checked rather than assumed.
448
445
  def fetch_one(q: Select(a)) -> Task(a, e)
449
- to_sql(q) |> Sql.fetch_one_raw |> Task.map_error(from_sql_error)
446
+ Sql.fetch_decoded(to_sql(q), q.result.decoder)
447
+ |> Task.and_then(Sql.exactly_one)
448
+ |> Task.map_error(from_sql_error)
450
449
  end
451
450
 
452
451
 
453
452
  # `fetch_one` where no row is an answer rather than an error. More than one
454
453
  # is still `TooManyRows`: nothing is dropped to make the type fit.
455
454
  def fetch_at_most_one(q: Select(a)) -> Task(Maybe(a), e)
456
- to_sql(q)
457
- |> Sql.fetch_one_raw
455
+ Sql.fetch_decoded(to_sql(q), q.result.decoder)
456
+ |> Task.and_then(Sql.exactly_one)
458
457
  |> Task.map(Just)
459
458
  |> Task.on_error(nothing_if_missing)
460
459
  |> Task.map_error(from_sql_error)
@@ -474,74 +473,42 @@ end
474
473
  # Runs the query and decodes every row it selected. No rows is an empty list,
475
474
  # not an error.
476
475
  def fetch_many(q: Select(a)) -> Task(List(a), e)
477
- to_sql(q) |> Sql.fetch_many_raw |> Task.map_error(from_sql_error)
476
+ Sql.fetch_decoded(to_sql(q), q.result.decoder) |> Task.map_error(from_sql_error)
478
477
  end
479
478
 
480
479
 
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
480
  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)
481
+ Sql.fetch_decoded(render(q, ["COUNT(*)"], []), Decode.index(0, Decode.int))
482
+ |> Task.and_then(Sql.exactly_one)
500
483
  |> Task.map_error(from_sql_error)
501
484
  end
502
485
 
503
486
 
504
- def count_of(r: CountRow) -> Int
505
- r.tally
506
- end
507
-
508
-
509
487
  # `SELECT EXISTS (...)`, which stops at the first row Postgres finds rather
510
488
  # than counting every one of them. `exists` is the `Expr(Bool)` for a `WHERE`,
511
489
  # where the keyword actually appears.
512
490
  def fetch_exists(q: Query(a)) -> Task(Bool, e)
513
491
  present = exists(q)
514
492
 
515
- Tuple.Tuple2("SELECT " ++ present.sql ++ " AS present", present.params)
516
- |> Sql.fetch_one_raw
517
- |> Task.map(present_of)
493
+ Sql.fetch_decoded(
494
+ ("SELECT " ++ present.sql, present.params),
495
+ Decode.index(0, Decode.bool),
496
+ )
497
+ |> Task.and_then(Sql.exactly_one)
518
498
  |> Task.map_error(from_sql_error)
519
499
  end
520
500
 
521
501
 
522
- def present_of(r: PresentRow) -> Bool
523
- r.present
524
- end
525
-
526
-
527
502
  # One column out of every row, for the reads whose answer is a list of values
528
503
  # rather than a list of rows:
529
504
  #
530
505
  # from(patients) |> where(p.archived |> eq(False)) |> fetch_values(p.id)
531
506
  #
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.
507
+ # Worth it only for a single column; more than one is what `select |> field`
508
+ # is for.
535
509
  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) })
510
+ Sql.fetch_decoded(render(q, [e.sql], e.params), Decode.index(0, Decode.decoder))
539
511
  |> Task.map_error(from_sql_error)
540
512
  end
541
513
 
542
514
 
543
- def value_of(r: ValueRow(a)) -> a
544
- r.value
545
- end
546
-
547
-
@@ -12,7 +12,7 @@ module Sql.Write exposing (
12
12
  insert,
13
13
  insert_all,
14
14
  Stamped,
15
- returning_with,
15
+ returning,
16
16
  timestamped,
17
17
  to_sql,
18
18
  update,
@@ -37,7 +37,7 @@ import Sql exposing (
37
37
  to_assigns,
38
38
  )
39
39
  import Sql.Query exposing (Query)
40
- import Decode exposing (Value)
40
+ import Decode exposing (Decoder, Value)
41
41
  import Encode
42
42
 
43
43
 
@@ -87,6 +87,11 @@ def do_update(build: s -> List(Assignment)) -> Action(s)
87
87
  end
88
88
 
89
89
 
90
+ def no_returning -> Selector(Int)
91
+ Selector([], [], Decode.fail("a write without RETURNING has no rows"))
92
+ end
93
+
94
+
90
95
  def insert(value: a, t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
91
96
  Write(
92
97
  InsertK,
@@ -95,7 +100,7 @@ def insert(value: a, t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
95
100
  t.set_cols,
96
101
  [to_assigns(value)],
97
102
  [],
98
- Selector([], []),
103
+ no_returning,
99
104
  Nothing,
100
105
  Nothing,
101
106
  )
@@ -110,7 +115,7 @@ def insert_all(values: List(a), t: Table(c, m, k, o, r, s)) -> Write(Int, c, s)
110
115
  t.set_cols,
111
116
  List.map(values, (v) -> { to_assigns(v) }),
112
117
  [],
113
- Selector([], []),
118
+ no_returning,
114
119
  Nothing,
115
120
  Nothing,
116
121
  )
@@ -125,7 +130,7 @@ def update(value: a, t: Table(c, m, k, o, r, s), key: k) -> Write(Int, c, s)
125
130
  t.set_cols,
126
131
  [without_created_at(without_key(t.pk, to_assigns(value)))],
127
132
  [key_predicate(t.pk, key)],
128
- Selector([], []),
133
+ no_returning,
129
134
  Nothing,
130
135
  Nothing,
131
136
  )
@@ -140,7 +145,7 @@ def delete(t: Table(c, m, k, o, r, s), key: k) -> Write(Int, c, s)
140
145
  t.set_cols,
141
146
  [],
142
147
  [key_predicate(t.pk, key)],
143
- Selector([], []),
148
+ no_returning,
144
149
  Nothing,
145
150
  Nothing,
146
151
  )
@@ -177,7 +182,7 @@ def update_all(
177
182
  t.set_cols,
178
183
  [build(cols_, t.set_cols)],
179
184
  [pred],
180
- Selector([], []),
185
+ no_returning,
181
186
  Nothing,
182
187
  Nothing,
183
188
  )
@@ -194,7 +199,7 @@ def update_many(rows_: List((k, a)), t: Table(c, m, k, o, r, s)) -> Write(Int, c
194
199
  t.set_cols,
195
200
  [source_assigns(t.pk, rows)],
196
201
  [pk_join(t.alias_, t.pk.columns)],
197
- Selector([], []),
202
+ no_returning,
198
203
  Just(source_of(t.name, rows)),
199
204
  Nothing,
200
205
  )
@@ -280,7 +285,7 @@ def delete_all(t: Table(c, m, k, o, r, s), pred_fn: c -> Expr(Bool)) -> Write(In
280
285
  t.set_cols,
281
286
  [],
282
287
  [pred],
283
- Selector([], []),
288
+ no_returning,
284
289
  Nothing,
285
290
  Nothing,
286
291
  )
@@ -303,7 +308,7 @@ def on_conflict(m: Write(ret, c, s), u: Unique(c, uk), action: Action(s)) -> Wri
303
308
  end
304
309
 
305
310
 
306
- def returning_with(m: Write(a, c, s), build: c -> Query(Selector(b))) -> Write(b, c, s)
311
+ def returning(m: Write(a, c, s), build: c -> Query(Selector(b))) -> Write(b, c, s)
307
312
  Write(
308
313
  m.kind,
309
314
  m.table,
@@ -607,12 +612,15 @@ end
607
612
  # insert, the new state of an update. A write with no `returning` selects
608
613
  # nothing, so this errors with `NotFound`.
609
614
  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)
615
+ Sql.fetch_decoded(to_sql(m), m.returning_selector.decoder)
616
+ |> Task.and_then(Sql.exactly_one)
617
+ |> Task.map_error(from_sql_error)
611
618
  end
612
619
 
613
620
 
614
621
  # The same for a write that returns many rows, as `insert_all` and
615
622
  # `update_all` do.
616
623
  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)
624
+ Sql.fetch_decoded(to_sql(m), m.returning_selector.decoder)
625
+ |> Task.map_error(from_sql_error)
618
626
  end
data/lib/jade-sql/sql.jd CHANGED
@@ -1,6 +1,7 @@
1
1
  module Sql exposing (
2
2
  Assignable,
3
3
  Assignment(..),
4
+ DateUnit(..),
4
5
  Col(..),
5
6
  Expr(..),
6
7
  NoJoins,
@@ -28,16 +29,20 @@ module Sql exposing (
28
29
  array_overlaps,
29
30
  array_remove,
30
31
  assign,
32
+ avg,
31
33
  coalesce,
32
34
  column,
33
35
  columns,
34
36
  count,
35
37
  count_all,
38
+ count_distinct,
36
39
  db_now,
37
40
  eq,
41
+ exactly_one,
38
42
  excluded,
39
43
  execute,
40
44
  execute_raw,
45
+ fetch_decoded,
41
46
  fetch_many_raw,
42
47
  fetch_one_raw,
43
48
  gt,
@@ -45,12 +50,16 @@ module Sql exposing (
45
50
  ilike,
46
51
  is_not_null,
47
52
  is_null,
53
+ json_text,
48
54
  jsonb_contains,
49
55
  jsonb_path_exists,
50
56
  left_columns,
51
57
  like,
58
+ lower,
52
59
  lt,
53
60
  lte,
61
+ max,
62
+ min,
54
63
  neg,
55
64
  neq,
56
65
  no_joins,
@@ -65,16 +74,21 @@ module Sql exposing (
65
74
  table,
66
75
  to_assigns,
67
76
  transaction,
77
+ trunc_date,
68
78
  unique,
69
79
  unkeyed,
70
80
  unsafe_cast,
81
+ upper,
71
82
  val,
72
83
  within,
73
84
  )
74
85
 
75
86
  import Encode exposing (Encodable, encode)
76
87
  import Decode exposing (Decodable, Decoder, Value)
88
+ import Calendar exposing (Date)
77
89
  import Clock exposing (Instant)
90
+ import Decimal exposing (Decimal)
91
+ import Show exposing (show)
78
92
 
79
93
 
80
94
  struct Expr(a) = {
@@ -85,7 +99,8 @@ struct Expr(a) = {
85
99
 
86
100
  struct Selector(a) = {
87
101
  columns_sql: List(String),
88
- params: List(Value)
102
+ params: List(Value),
103
+ decoder: Decoder(a)
89
104
  }
90
105
 
91
106
 
@@ -289,7 +304,7 @@ end
289
304
  # so this is for the positions that cannot: a constant field in a projection
290
305
  # or a JSON document.
291
306
  #
292
- # select(Row(_, _)) |> field(c.id) |> field_as(val("patient"), "kind")
307
+ # select(Row(_, _)) |> field(c.id) |> field(val("patient"))
293
308
  # Json.object(Row(_)) |> Json.prop("kind", val("patient"))
294
309
  #
295
310
  # Every library that has this calls it `val`, and none of them has a word for
@@ -437,6 +452,74 @@ def neg(e: Expr(Int)) -> Expr(Int)
437
452
  end
438
453
 
439
454
 
455
+ # Aggregates over no rows are NULL, so all but the counts come back in `Maybe`.
456
+ def max(e: Expr(a)) -> Expr(Maybe(a))
457
+ Expr("MAX(" ++ e.sql ++ ")", e.params)
458
+ end
459
+
460
+
461
+ def min(e: Expr(a)) -> Expr(Maybe(a))
462
+ Expr("MIN(" ++ e.sql ++ ")", e.params)
463
+ end
464
+
465
+
466
+ # Postgres averages integers as `numeric`, which is exact.
467
+ def avg(e: Expr(Int)) -> Expr(Maybe(Decimal))
468
+ Expr("AVG(" ++ e.sql ++ ")", e.params)
469
+ end
470
+
471
+
472
+ def count_distinct(e: Expr(a)) -> Expr(Int)
473
+ Expr("COUNT(DISTINCT " ++ e.sql ++ ")", e.params)
474
+ end
475
+
476
+
477
+ def lower(e: Expr(String)) -> Expr(String)
478
+ Expr("LOWER(" ++ e.sql ++ ")", e.params)
479
+ end
480
+
481
+
482
+ def upper(e: Expr(String)) -> Expr(String)
483
+ Expr("UPPER(" ++ e.sql ++ ")", e.params)
484
+ end
485
+
486
+
487
+ type DateUnit
488
+ = Year
489
+ | Quarter
490
+ | Month
491
+ | Week
492
+ | Day
493
+
494
+
495
+ # The first day of the unit `d` falls in. `date_trunc` has no date form: the
496
+ # date goes in as a timestamp, which a bound one would not name on its own, and
497
+ # the result comes back to a date.
498
+ def trunc_date(unit: DateUnit, d: Expr(Date)) -> Expr(Date)
499
+ Expr(
500
+ "date_trunc('" ++ unit_name(unit) ++ "', (" ++ d.sql ++ ")::timestamp)::date",
501
+ d.params,
502
+ )
503
+ end
504
+
505
+
506
+ def unit_name(unit: DateUnit) -> String
507
+ case unit
508
+ in Year then "year"
509
+ in Quarter then "quarter"
510
+ in Month then "month"
511
+ in Week then "week"
512
+ in Day then "day"
513
+ end
514
+ end
515
+
516
+
517
+ # A key's text out of a JSON object, `NULL` where the key is absent.
518
+ def json_text(col: Expr(Value), key: String) -> Expr(Maybe(String))
519
+ Expr("(" ++ col.sql ++ " ->> ?)", col.params ++ [encode(key)])
520
+ end
521
+
522
+
440
523
  # PG array predicates.
441
524
  # All bind the array as a single param (`$1`) instead of expanding to
442
525
  # `ARRAY[$1,...,$N]`. The pg driver maps Ruby Array → PG array; PG
@@ -701,6 +784,7 @@ uses JadeSql::Runtime with
701
784
  port_execute_count : String, List(Value) -> Task(Int, SqlError),
702
785
  port_execute_one : String, List(Value) -> Task(a, SqlError),
703
786
  port_execute_many : String, List(Value) -> Task(List(a), SqlError),
787
+ port_execute_rows : String, List(Value) -> Task(Value, SqlError),
704
788
  port_begin : Task(Bool, SqlError),
705
789
  port_commit : Task(Bool, SqlError),
706
790
  port_rollback : Task(Bool, SqlError)
@@ -725,6 +809,32 @@ def fetch_many_raw(p: (String, List(Value))) -> Task(List(a), SqlError)
725
809
  end
726
810
 
727
811
 
812
+ # The rows as arrays, each through `decoder`: a projection decodes by
813
+ # position, so a column's name never has to match the field it lands in.
814
+ def fetch_decoded(p: (String, List(Value)), decoder: Decoder(a)) -> Task(List(a), SqlError)
815
+ (sql, params) = p
816
+
817
+ port_execute_rows(sql, params)
818
+ |> Task.and_then((rows) -> { decode_rows(rows, decoder) })
819
+ end
820
+
821
+
822
+ def decode_rows(rows: Value, decoder: Decoder(a)) -> Task(List(a), SqlError)
823
+ Decode.decode(Decode.list(decoder), rows)
824
+ |> Result.map_error((e) -> { DbError("row did not decode: " ++ show(e)) })
825
+ |> Task.from_result
826
+ end
827
+
828
+
829
+ def exactly_one(rows: List(a)) -> Task(a, SqlError)
830
+ case rows
831
+ in [row] then Task.succeed(row)
832
+ in [] then Task.fail(NotFound)
833
+ else Task.fail(TooManyRows)
834
+ end
835
+ end
836
+
837
+
728
838
  def execute(r: r) -> Task(Int, e)
729
839
  to_sql(r) |> execute_raw |> Task.map_error(from_sql_error)
730
840
  end
@@ -1,3 +1,3 @@
1
1
  module JadeSql
2
- VERSION = '0.9.1'
2
+ VERSION = '0.10.0'
3
3
  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.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - agustin
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-18 00:00:00.000000000 Z
10
+ date: 2026-09-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: jade-lang