jade-sql 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +68 -0
- data/docs/building.md +491 -0
- data/docs/running.md +103 -0
- data/exe/jade-sql +67 -0
- data/lib/jade-sql/bin/generate_schema.rb +219 -0
- data/lib/jade-sql/runtime.rb +193 -0
- data/lib/jade-sql/sql/loader.jd +30 -0
- data/lib/jade-sql/sql/mutation.jd +268 -0
- data/lib/jade-sql/sql/query.jd +313 -0
- data/lib/jade-sql/sql/uuid.jd +126 -0
- data/lib/jade-sql/sql.jd +421 -0
- data/lib/jade-sql/tasks.rake +19 -0
- data/lib/jade-sql/uuid_runtime.rb +16 -0
- data/lib/jade-sql/version.rb +3 -0
- data/lib/jade-sql.rb +42 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e5b511ba76f20947fbccd7ebb7800746ca4ff7deaa7db27f02c0f9e0c2063fa5
|
|
4
|
+
data.tar.gz: 4f280996410b7a415c14ce431879074d3ea14db4da9305358595738a8f31f674
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: df9d4acfa3ac509e4a9730a727befae3197cdd9abb9959c60d107191fb574898e1d75ca23eb7814ab5a6b80a81b0e7e5e2d2131954a47053726c0a1e3354e720
|
|
7
|
+
data.tar.gz: 582f7bcacd1543c00d6efc7710762fe1b6b9a388c8e71c2fedaf006229d3ac7bce49510473e8ae3b97773314c6ead9445b5f38c8d6aeb678cfbc6c573adae763
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agustin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# jade-sql
|
|
2
|
+
|
|
3
|
+
Type-safe SQL for Jade. Builds queries and mutations from a generated
|
|
4
|
+
schema, renders them to `(String, List(Value))`, and runs them against
|
|
5
|
+
ActiveRecord via a Task port that auto-decodes rows into typed Jade
|
|
6
|
+
structs.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# Gemfile
|
|
12
|
+
gem 'jade-sql'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Its dependency, the `jade-lang` gem (the Jade compiler/runtime), resolves
|
|
16
|
+
from RubyGems automatically.
|
|
17
|
+
|
|
18
|
+
To execute queries at runtime, opt into the AR-backed task port:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# config/initializers/jade_sql.rb (or similar)
|
|
22
|
+
require 'jade-sql/runtime'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To get the rake task for schema generation:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# Rakefile (or lib/tasks/jade.rake)
|
|
29
|
+
load Gem.find_files('jade-sql/tasks.rake').first
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## At a glance
|
|
33
|
+
|
|
34
|
+
Generate a typed schema from `db/structure.sql`, then build a query against
|
|
35
|
+
it. The result is a `Q` you render with `to_sql` or run with `fetch_*`; rows
|
|
36
|
+
decode straight into your struct.
|
|
37
|
+
|
|
38
|
+
```jade
|
|
39
|
+
import Sql exposing (Selector, eq, to_expr)
|
|
40
|
+
import Sql.Query exposing (Q, field, from, join, select, where)
|
|
41
|
+
import Schema exposing (patients, appointments)
|
|
42
|
+
|
|
43
|
+
struct Visit = {
|
|
44
|
+
name: String,
|
|
45
|
+
reason: String
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def scheduled_visits -> Q(Selector(Visit))
|
|
49
|
+
p <- from(patients)
|
|
50
|
+
a <- join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
51
|
+
|
|
52
|
+
select(Visit(_, _))
|
|
53
|
+
|> field(p.name)
|
|
54
|
+
|> field(a.reason)
|
|
55
|
+
|> where(a.status |> eq(to_expr("scheduled")))
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Run it with `scheduled_visits |> fetch_many` — a `Task(List(Visit), SqlError)`
|
|
60
|
+
that decodes each row into `Visit`.
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
- [Building SQL](docs/building.md) — generate a schema, build queries
|
|
65
|
+
(joins, sorting/grouping, pagination, aggregates, arrays, JSONB) and
|
|
66
|
+
mutations (insert/update/delete, RETURNING, timestamps, UUIDs).
|
|
67
|
+
- [Running queries and mutations](docs/running.md) — `fetch_*` / `execute`,
|
|
68
|
+
transactions, and testing without a database.
|
data/docs/building.md
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# Building SQL
|
|
2
|
+
|
|
3
|
+
Generate a typed schema from your database, then build queries and
|
|
4
|
+
mutations against it. To run what you build, see [running.md](running.md).
|
|
5
|
+
|
|
6
|
+
## Generate `schema.jd` from `db/structure.sql`
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bundle exec rake jade:schema
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Reads `db/structure.sql`, writes `app/jade/schema.jd`. Knobs:
|
|
13
|
+
|
|
14
|
+
| ENV | Default | What |
|
|
15
|
+
|---------|----------------------|-------------------------------------------|
|
|
16
|
+
| INPUT | `db/structure.sql` | source DDL file |
|
|
17
|
+
| OUTPUT | `app/jade/schema.jd` | destination |
|
|
18
|
+
| TABLES | (all) | comma-separated whitelist |
|
|
19
|
+
| MODULE | `Schema` | module name in the generated file |
|
|
20
|
+
|
|
21
|
+
Multiple schemas in one app (e.g. for gradual migration):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle exec rake jade:schema \
|
|
25
|
+
TABLES=invoices,charges \
|
|
26
|
+
MODULE=Schema.Billing \
|
|
27
|
+
OUTPUT=app/jade/schema/billing.jd
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Type map: `bigint`/`integer`/`smallint` → `Int`, `varchar`/`text`/`char` →
|
|
31
|
+
`String`, `boolean` → `Bool`, `jsonb`/`json` → `Decode.Value`, `date` →
|
|
32
|
+
`Calendar.Date`, `timestamp` → `Clock.Instant`, `uuid` → `Uuid` (from
|
|
33
|
+
`Sql.Uuid`). Unknown types fail loudly with the table+column name.
|
|
34
|
+
`bytea` and `decimal`/`numeric` aren't wired into the type map yet —
|
|
35
|
+
`bytea` has a natural target in jade's `Bytes`, and a decimal can be
|
|
36
|
+
modeled as a struct of `Int`s.
|
|
37
|
+
|
|
38
|
+
For each table, the generator emits:
|
|
39
|
+
|
|
40
|
+
```jade
|
|
41
|
+
struct PatientsCols = { id: Expr(Int), name: Expr(String), ... }
|
|
42
|
+
struct MaybePatientsCols = { id: Expr(Maybe(Int)), name: Expr(Maybe(String)), ... }
|
|
43
|
+
struct PatientsRow = { id: Int, name: String, ... }
|
|
44
|
+
|
|
45
|
+
def patients -> Table(PatientsCols, MaybePatientsCols)
|
|
46
|
+
table("patients", "patients", ..., ["id"])
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Strict cols mirror NOT NULL constraints; the maybe version wraps every
|
|
51
|
+
field in `Maybe` for left-join projections. The default alias is the
|
|
52
|
+
table name; override per-call with `aliased` (see joins below).
|
|
53
|
+
|
|
54
|
+
## Build queries
|
|
55
|
+
|
|
56
|
+
```jade
|
|
57
|
+
import Sql exposing (Selector, eq, to_expr)
|
|
58
|
+
import Sql.Query exposing (Q, field, from, join, select, where)
|
|
59
|
+
import Schema exposing (patients, appointments)
|
|
60
|
+
|
|
61
|
+
struct Visit = {
|
|
62
|
+
name: String,
|
|
63
|
+
reason: String
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def scheduled_visits -> Q(Selector(Visit))
|
|
67
|
+
p <- from(patients)
|
|
68
|
+
a <- join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
69
|
+
|
|
70
|
+
select(Visit(_, _))
|
|
71
|
+
|> field(p.name)
|
|
72
|
+
|> field(a.reason)
|
|
73
|
+
|> where(a.status |> eq(to_expr("scheduled")))
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Notes:
|
|
78
|
+
- `<-` is bind-chain — exposes each joined table's columns to the rest of
|
|
79
|
+
the chain. `p` and `a` are the projected column accessors.
|
|
80
|
+
- `select(Visit(_, _))` uses placeholders. Each subsequent `field(...)`
|
|
81
|
+
fills one slot in declared order.
|
|
82
|
+
- `to_sql(q)` returns `(String, List(Value))`.
|
|
83
|
+
|
|
84
|
+
### Sorting and grouping
|
|
85
|
+
|
|
86
|
+
`order(q, e)` appends an ASC term, `order_desc(q, e)` a DESC term, and
|
|
87
|
+
`group(q, e)` a GROUP BY column. Repeated calls accumulate in declared
|
|
88
|
+
order — e.g. counting visits per patient, busiest first:
|
|
89
|
+
|
|
90
|
+
```jade
|
|
91
|
+
import Sql exposing (column, count_all)
|
|
92
|
+
import Sql.Query exposing (group, order, order_desc)
|
|
93
|
+
|
|
94
|
+
struct VisitCount = {
|
|
95
|
+
name: String,
|
|
96
|
+
visits: Int
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def visit_counts -> Q(Selector(VisitCount))
|
|
100
|
+
p <- from(patients)
|
|
101
|
+
a <- join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
102
|
+
|
|
103
|
+
select(VisitCount(_, _))
|
|
104
|
+
|> field(p.name)
|
|
105
|
+
|> field(count_all)
|
|
106
|
+
|> group(column("p", "name"))
|
|
107
|
+
|> order_desc(count_all)
|
|
108
|
+
|> order(column("p", "name"))
|
|
109
|
+
end
|
|
110
|
+
# ... GROUP BY p.name ORDER BY COUNT(*) DESC, p.name
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`HAVING` and `CASE` aren't built in yet — for filtering aggregates or
|
|
114
|
+
conditional expressions, fall back to the raw-SQL escape hatch
|
|
115
|
+
(`execute_*`). Basic aggregates (`SUM`, `COUNT`) and the
|
|
116
|
+
null-handling primitive (`coalesce`) are typed; see *Aggregates,
|
|
117
|
+
COALESCE* below.
|
|
118
|
+
|
|
119
|
+
### Pagination
|
|
120
|
+
|
|
121
|
+
`limit(q, n)` and `offset(q, n)` append `LIMIT`/`OFFSET` clauses. Both
|
|
122
|
+
take a plain `Int` and render inline (not as parameters), so the
|
|
123
|
+
returned `List(Value)` is unaffected:
|
|
124
|
+
|
|
125
|
+
```jade
|
|
126
|
+
import Sql.Query exposing(limit, offset)
|
|
127
|
+
|
|
128
|
+
def page(n: Int) -> Q(Selector(Visit))
|
|
129
|
+
scheduled_visits
|
|
130
|
+
|> limit(20)
|
|
131
|
+
|> offset(n * 20)
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Calling `limit`/`offset` more than once overrides the previous value
|
|
136
|
+
(last call wins).
|
|
137
|
+
|
|
138
|
+
### Self-joins
|
|
139
|
+
|
|
140
|
+
The schema's default alias = table name. Override with `aliased`:
|
|
141
|
+
|
|
142
|
+
```jade
|
|
143
|
+
p <- from(patients)
|
|
144
|
+
c <- patients |> aliased("c") |> join((c) -> { p.id |> eq(c.parent_id) })
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Left joins with nullable views
|
|
148
|
+
|
|
149
|
+
`left_join` switches the joined table to its maybe-column view:
|
|
150
|
+
|
|
151
|
+
```jade
|
|
152
|
+
p <- from(patients)
|
|
153
|
+
a <- left_join(appointments, (a) -> { p.id |> eq(a.patient_id) })
|
|
154
|
+
# `a` is MaybeAppointmentsCols; field types are Expr(Maybe(String)) etc.
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
For predicates that lift a non-null column into the nullable side,
|
|
158
|
+
`nullable`:
|
|
159
|
+
|
|
160
|
+
```jade
|
|
161
|
+
p.id |> nullable |> eq(a.patient_id) # Expr(Int) → Expr(Maybe(Int))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Phantom-type rewrap with `cast`
|
|
165
|
+
|
|
166
|
+
`cast(e: Expr(a)) -> Expr(b)` widens a column's phantom type — useful
|
|
167
|
+
for projecting a `VARCHAR` column into a typed enum field whose
|
|
168
|
+
`Decodable(b)` instance does the actual parsing at row decode:
|
|
169
|
+
|
|
170
|
+
```jade
|
|
171
|
+
struct Appointment = { id: Int, status: Status, ... }
|
|
172
|
+
|
|
173
|
+
# status column is VARCHAR; Status has a Decodable(Status) impl that parses
|
|
174
|
+
# "scheduled" / "completed" into the variants.
|
|
175
|
+
select(Appointment(_, _, ...))
|
|
176
|
+
|> field(a.id)
|
|
177
|
+
|> field(a.status |> cast) # Expr(String) → Expr(Status)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Same shape as `nullable` — pure phantom-type rewrap, no runtime
|
|
181
|
+
transformation. The runtime decoder (`Decodable(Status)`) is what
|
|
182
|
+
actually converts the column value; `cast` just teaches the SQL
|
|
183
|
+
builder that the projection is intended. If `Decodable(b)` can't
|
|
184
|
+
parse the column's actual values, the failure surfaces at row
|
|
185
|
+
decode time, not at type check.
|
|
186
|
+
|
|
187
|
+
### Aggregates, COALESCE
|
|
188
|
+
|
|
189
|
+
A small typed surface for SQL functions that would otherwise force you
|
|
190
|
+
into hand-built `Expr` strings. All compose with the rest of the
|
|
191
|
+
builder — params stitch in declaration order automatically.
|
|
192
|
+
|
|
193
|
+
| Function | SQL | Notes |
|
|
194
|
+
|------------------------------------------------|--------------------|----------------------------------------|
|
|
195
|
+
| `sum(Expr(Int)) -> Expr(Maybe(Int))` | `SUM(e)` | `NULL` on empty group → `Maybe`. |
|
|
196
|
+
| `count(Expr(a)) -> Expr(Int)` | `COUNT(e)` | Counts non-null rows for the column. |
|
|
197
|
+
| `count_all -> Expr(Int)` | `COUNT(*)` | Total row count. |
|
|
198
|
+
| `coalesce(Expr(Maybe(a)), Expr(a)) -> Expr(a)` | `COALESCE(e, def)` | Drops the `Maybe` with a fallback. |
|
|
199
|
+
| `neg(Expr(Int)) -> Expr(Int)` | `-(e)` | Unary minus. |
|
|
200
|
+
|
|
201
|
+
Worked example — count visits and the most recent visit number,
|
|
202
|
+
coalesced to 0 when a patient has none:
|
|
203
|
+
|
|
204
|
+
```jade
|
|
205
|
+
import Sql exposing (coalesce, column, count_all, sum, to_expr)
|
|
206
|
+
|
|
207
|
+
select(Totals(_, _))
|
|
208
|
+
|> field(count_all)
|
|
209
|
+
|> field(coalesce(sum(column("a", "visit_no")), to_expr(0)))
|
|
210
|
+
# SELECT COUNT(*), COALESCE(SUM(a.visit_no), ?)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
For `CASE WHEN`, `HAVING`, and arithmetic, fall back to the raw-`Expr`
|
|
214
|
+
escape hatch until they get a typed builder.
|
|
215
|
+
|
|
216
|
+
### Postgres arrays
|
|
217
|
+
|
|
218
|
+
Typed predicates on `text[]` / `int[]` / `uuid[]` columns. All bind
|
|
219
|
+
the array as a single param (`$1`) — `pg` maps Ruby `Array` to a PG
|
|
220
|
+
array natively, and PG infers the element type from the column on
|
|
221
|
+
the other side of the operator. No `ARRAY[$1,...,$N]` expansion, no
|
|
222
|
+
`ARRAY[]::t[]` cast needed for empty inputs.
|
|
223
|
+
|
|
224
|
+
| Function | SQL |
|
|
225
|
+
|----------------------------------------------------------------|------------------------------|
|
|
226
|
+
| `array_overlaps(Expr(List(a)), List(a)) -> Expr(Bool)` | `col && ?` (any-of) |
|
|
227
|
+
| `array_has(Expr(List(a)), a) -> Expr(Bool)` | `? = ANY(col)` (membership) |
|
|
228
|
+
| `array_contains(Expr(List(a)), List(a)) -> Expr(Bool)` | `col @> ?` (all-of) |
|
|
229
|
+
| `array_contained_by(Expr(List(a)), List(a)) -> Expr(Bool)` | `col <@ ?` (subset) |
|
|
230
|
+
| `array_length(Expr(List(a))) -> Expr(Int)` | `cardinality(col)` |
|
|
231
|
+
|
|
232
|
+
Example — filter appointments whose tag set overlaps any selected chip:
|
|
233
|
+
|
|
234
|
+
```jade
|
|
235
|
+
import Sql exposing (array_overlaps, column)
|
|
236
|
+
|
|
237
|
+
def filter_by_tags(selected: List(String)) -> Expr(Bool)
|
|
238
|
+
array_overlaps(column("a", "tags"), selected)
|
|
239
|
+
end
|
|
240
|
+
# WHERE a.tags && ? (param: ["urgent","followup"])
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`array_length` uses `cardinality(col)` rather than Postgres'
|
|
244
|
+
`array_length(col, 1)` because `cardinality` is non-null (returns 0
|
|
245
|
+
on empty). Filter untagged rows with
|
|
246
|
+
`array_length(column("a", "tags")) |> eq(to_expr(0))`.
|
|
247
|
+
|
|
248
|
+
Mutation ops for partial array updates:
|
|
249
|
+
|
|
250
|
+
| Function | SQL |
|
|
251
|
+
|---------------------------------------------------------------------|------------------------------|
|
|
252
|
+
| `array_append(Expr(List(a)), a) -> Expr(List(a))` | `array_append(col, ?)` |
|
|
253
|
+
| `array_remove(Expr(List(a)), a) -> Expr(List(a))` | `array_remove(col, ?)` |
|
|
254
|
+
| `array_concat(Expr(List(a)), Expr(List(a))) -> Expr(List(a))` | `left ‖ right` |
|
|
255
|
+
|
|
256
|
+
Use these in `update_all` to avoid rewriting an array column wholesale:
|
|
257
|
+
|
|
258
|
+
```jade
|
|
259
|
+
appointments
|
|
260
|
+
|> update_all(
|
|
261
|
+
(a) -> { a.id |> eq(to_expr(aid)) },
|
|
262
|
+
(a) -> { [a.tags |> set_(array_append(a.tags, new_tag))] },
|
|
263
|
+
)
|
|
264
|
+
# UPDATE appointments SET tags = array_append(tags, ?) WHERE id = ?
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Out of scope: `unnest`, `array_agg`. Add when a caller hits them.
|
|
268
|
+
|
|
269
|
+
### JSONB predicates
|
|
270
|
+
|
|
271
|
+
| Function | SQL |
|
|
272
|
+
|-----------------------------------------------------------|--------------------|
|
|
273
|
+
| `jsonb_contains(Expr(Value), a) -> Expr(Bool)` | `col @> ?` |
|
|
274
|
+
| `jsonb_path_exists(Expr(Value), String) -> Expr(Bool)` | `col @? ?::jsonpath` |
|
|
275
|
+
|
|
276
|
+
`jsonb_contains` auto-encodes the value via its `Encodable` instance,
|
|
277
|
+
so you can pass any record / scalar / list directly:
|
|
278
|
+
|
|
279
|
+
```jade
|
|
280
|
+
import Sql exposing (column, jsonb_contains, jsonb_path_exists)
|
|
281
|
+
|
|
282
|
+
struct KindMatch = { kind: String }
|
|
283
|
+
|
|
284
|
+
# WHERE r.meta @> ? (param: { "kind": "referral" })
|
|
285
|
+
def matches_kind(k: String) -> Expr(Bool)
|
|
286
|
+
jsonb_contains(column("r", "meta"), KindMatch(k))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# WHERE r.meta @? ?::jsonpath (param: "$.priority ? (@ > 1)")
|
|
290
|
+
def has_priority_gt(path: String) -> Expr(Bool)
|
|
291
|
+
jsonb_path_exists(column("r", "meta"), path)
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The `@?` operator requires `jsonpath` on the right; the param binds as
|
|
296
|
+
text and gets cast at the SQL level.
|
|
297
|
+
|
|
298
|
+
## Build mutations
|
|
299
|
+
|
|
300
|
+
Define codec interfaces for your domain type:
|
|
301
|
+
|
|
302
|
+
```jade
|
|
303
|
+
import Sql exposing(Assignment, SqlMapper, Identified, assign)
|
|
304
|
+
import Encode exposing(encode)
|
|
305
|
+
import Decode exposing(Value)
|
|
306
|
+
|
|
307
|
+
struct Patient = { id: Int, name: String, mrn: String }
|
|
308
|
+
|
|
309
|
+
implements SqlMapper(Patient) with
|
|
310
|
+
to_assigns: encode_patient
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
implements Identified(Patient) with
|
|
314
|
+
pk_values: encode_patient_pk
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def encode_patient(p: Patient) -> List(Assignment)
|
|
318
|
+
[
|
|
319
|
+
assign("name", p.name),
|
|
320
|
+
assign("mrn", p.mrn)
|
|
321
|
+
]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def encode_patient_pk(p: Patient) -> List(Value)
|
|
325
|
+
[encode(p.id)]
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`assign(col, value)` is shorthand for
|
|
330
|
+
`Assignment(col, "?", [encode(value)])`. For non-`?` placeholders
|
|
331
|
+
(e.g. `"visit_no + ?"` for increments) use the `Assignment(...)`
|
|
332
|
+
constructor directly.
|
|
333
|
+
|
|
334
|
+
Then the mutation API works on values directly:
|
|
335
|
+
|
|
336
|
+
```jade
|
|
337
|
+
import Sql.Mutation exposing(insert, update, delete, insert_all, update_all, delete_all, to_sql)
|
|
338
|
+
|
|
339
|
+
p |> insert(patients) |> to_sql # INSERT INTO patients (name, mrn) VALUES (?, ?)
|
|
340
|
+
p |> update(patients) |> to_sql # UPDATE patients SET name = ?, mrn = ? WHERE id = ?
|
|
341
|
+
p |> delete(patients) |> to_sql # DELETE FROM patients WHERE id = ?
|
|
342
|
+
|
|
343
|
+
[p1, p2] |> insert_all(patients) |> to_sql
|
|
344
|
+
|
|
345
|
+
appointments
|
|
346
|
+
|> update_all((a) -> { a.status |> eq(to_expr("scheduled")) },
|
|
347
|
+
(a) -> { [a.cancelled |> set_(to_expr(True))] })
|
|
348
|
+
|> to_sql
|
|
349
|
+
|
|
350
|
+
appointments
|
|
351
|
+
|> delete_all((a) -> { a.cancelled |> eq(to_expr(True)) })
|
|
352
|
+
|> to_sql
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### RETURNING
|
|
356
|
+
|
|
357
|
+
`returning` is the mutation-side counterpart to `select` for queries.
|
|
358
|
+
It takes a closure that receives the table's column accessors and
|
|
359
|
+
builds a Q-wrapped selector projecting them into a target type. The
|
|
360
|
+
Q wrapper is just to share the same `select`/`field` builders as
|
|
361
|
+
queries — `returning` extracts the inner `Selector` and discards the
|
|
362
|
+
empty Q state.
|
|
363
|
+
|
|
364
|
+
```jade
|
|
365
|
+
import Sql exposing(Selector)
|
|
366
|
+
import Sql.Query exposing(select, field)
|
|
367
|
+
import Sql.Mutation exposing(insert, returning, to_sql)
|
|
368
|
+
|
|
369
|
+
# INSERT INTO patients (name, mrn) VALUES (?, ?) RETURNING id, name, mrn
|
|
370
|
+
np
|
|
371
|
+
|> insert(patients)
|
|
372
|
+
|> returning((p) -> {
|
|
373
|
+
select(Patient(_, _, _))
|
|
374
|
+
|> field(p.id)
|
|
375
|
+
|> field(p.name)
|
|
376
|
+
|> field(p.mrn)
|
|
377
|
+
})
|
|
378
|
+
|> to_sql # or |> fetch_one to run
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Bonus: the projector can be defined once and shared between SELECT
|
|
382
|
+
queries and RETURNING — both contexts now take the same `cols ->
|
|
383
|
+
Q(Selector(target))` shape, so a single `def patient_projector(p)`
|
|
384
|
+
works for `from(patients) |> patient_projector` (query) and
|
|
385
|
+
`... |> returning(patient_projector)` (RETURNING).
|
|
386
|
+
|
|
387
|
+
Combined with `Sql.fetch_one`, the inserted row decodes into the
|
|
388
|
+
target struct:
|
|
389
|
+
|
|
390
|
+
```jade
|
|
391
|
+
def create(np: NewPatient) -> Task(Patient, SqlError)
|
|
392
|
+
np |> insert(patients) |> returning((p) -> {
|
|
393
|
+
select(Patient(_, _, _))
|
|
394
|
+
|> field(p.id)
|
|
395
|
+
|> field(p.name)
|
|
396
|
+
|> field(p.mrn)
|
|
397
|
+
})
|
|
398
|
+
|> fetch_one
|
|
399
|
+
end
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`insert` / `update` / `delete` need `SqlMapper(a)` + `Identified(a)`.
|
|
403
|
+
`insert_all` needs only `SqlMapper`. `update_all`/`delete_all` build
|
|
404
|
+
the SET / WHERE clauses directly from the column accessors — no codec.
|
|
405
|
+
|
|
406
|
+
`SqlMapper` is also implemented for `List(Assignment)` itself, so you
|
|
407
|
+
can pass an assignment list to `insert` directly when you've already
|
|
408
|
+
built it (e.g. from a sparse changeset):
|
|
409
|
+
|
|
410
|
+
```jade
|
|
411
|
+
sparse_changes
|
|
412
|
+
|> List.and_then(field_to_assigns)
|
|
413
|
+
|> insert(_, patients)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Timestamps
|
|
417
|
+
|
|
418
|
+
`Mutation.insert`/`update` emit only the columns you explicitly set —
|
|
419
|
+
they don't auto-fill `created_at` / `updated_at`. Two ways to handle
|
|
420
|
+
NOT NULL timestamp columns:
|
|
421
|
+
|
|
422
|
+
**1. DB-side defaults (recommended).** Let the schema own timestamp
|
|
423
|
+
policy:
|
|
424
|
+
|
|
425
|
+
```sql
|
|
426
|
+
ALTER TABLE patients ALTER COLUMN created_at SET DEFAULT now();
|
|
427
|
+
ALTER TABLE patients ALTER COLUMN updated_at SET DEFAULT now();
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The mutation builder stays a thin SQL emitter; the DB fills in
|
|
431
|
+
defaults for any column the INSERT didn't list.
|
|
432
|
+
|
|
433
|
+
**2. Explicit injection in app code.** When you want Jade to carry the
|
|
434
|
+
timestamp values (Rails-style), tack assignments onto the list before
|
|
435
|
+
the call:
|
|
436
|
+
|
|
437
|
+
```jade
|
|
438
|
+
def with_timestamps(assigns: List(Assignment), now: Instant)
|
|
439
|
+
-> List(Assignment)
|
|
440
|
+
assigns ++ [assign("created_at", now), assign("updated_at", now)]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def create(p: Patient, now: Instant) -> Mutation(Int, PatientsCols)
|
|
444
|
+
encode_patient(p)
|
|
445
|
+
|> with_timestamps(now)
|
|
446
|
+
|> insert(_, patients)
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### UUIDs
|
|
451
|
+
|
|
452
|
+
`Sql.Uuid` defines an opaque `Uuid` type plus generation/parse helpers.
|
|
453
|
+
The schema generator emits `Uuid` for `uuid` columns:
|
|
454
|
+
|
|
455
|
+
```jade
|
|
456
|
+
import Sql.Uuid exposing (Uuid, v4, v7, parse, to_string)
|
|
457
|
+
|
|
458
|
+
# DB-side generation (recommended for PKs):
|
|
459
|
+
# ALTER TABLE patients ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
|
460
|
+
|
|
461
|
+
# App-side generation (idempotency keys, child-row pre-linking, …):
|
|
462
|
+
def make_request_id -> Task(Uuid, Never)
|
|
463
|
+
v7 # time-ordered, friendlier to DB index locality than v4
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
`v4` and `v7` are zero-arg Tasks (`def v4 -> Task(Uuid, Never)`); call
|
|
468
|
+
them without parens. `parse(String) -> Maybe(Uuid)` accepts the
|
|
469
|
+
canonical 8-4-4-4-12 form (case-insensitive, stored lowercase).
|
|
470
|
+
`to_string(Uuid) -> String` for the canonical text form.
|
|
471
|
+
|
|
472
|
+
`Encodable(Uuid)` and `Decodable(Uuid)` impls are in the module, so
|
|
473
|
+
Uuids flow through SQL params, RETURNING decoding, and JSON boundaries
|
|
474
|
+
without extra wiring.
|
|
475
|
+
|
|
476
|
+
#### Short (Base64) display form
|
|
477
|
+
|
|
478
|
+
The canonical 36-char form is too noisy for URLs / admin UIs / logs.
|
|
479
|
+
`to_b64` / `from_b64` round-trip a Uuid through 22-char url-safe
|
|
480
|
+
Base64 (no padding), preserving the v7 time-ordering:
|
|
481
|
+
|
|
482
|
+
```jade
|
|
483
|
+
import Sql.Uuid exposing (to_b64, from_b64)
|
|
484
|
+
|
|
485
|
+
to_b64(u) # "VQ6EAOKbQdSnFkRmVUQAAA"
|
|
486
|
+
from_b64("VQ6EAOKbQdSnFkRmVUQAAA") # Just(u)
|
|
487
|
+
from_b64("nope") # Nothing
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`from_b64` returns `Nothing` for non-base64 input or for valid base64
|
|
491
|
+
that decodes to a length other than 16 bytes.
|
data/docs/running.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Running queries and mutations
|
|
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:
|
|
7
|
+
|
|
8
|
+
```jade
|
|
9
|
+
import Sql exposing (SqlError, execute, execute_raw, fetch_many, fetch_one)
|
|
10
|
+
|
|
11
|
+
# Affected count for INSERT/UPDATE/DELETE
|
|
12
|
+
def reschedule(a: Appointment) -> Task(Int, SqlError)
|
|
13
|
+
a |> update(appointments) |> execute
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A single row, decoded into Patient
|
|
17
|
+
def find(id: Int) -> Task(Patient, SqlError)
|
|
18
|
+
patient_by_id_query(id) |> fetch_one
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Many rows, decoded
|
|
22
|
+
def all -> Task(List(Patient), SqlError)
|
|
23
|
+
all_patients_query |> fetch_many
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raw SQL escape hatch — bypass the typed builders
|
|
27
|
+
def count_active -> Task(Int, SqlError)
|
|
28
|
+
execute_raw(("SELECT COUNT(*) FROM patients WHERE archived = ?", [Encode.encode(False)]))
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
Row decoding is automatic — the caller's type (`Patient`, `List(Patient)`)
|
|
40
|
+
threads its `Decodable` instance into the polymorphic port. The runtime
|
|
41
|
+
returns plain Ruby hashes from AR, and they're decoded into typed structs
|
|
42
|
+
at the boundary.
|
|
43
|
+
|
|
44
|
+
`SqlError` variants:
|
|
45
|
+
- `DbError(String)` — AR `StatementInvalid` message
|
|
46
|
+
- `NotFound` — `fetch_one` with zero rows
|
|
47
|
+
- `NotUnique` — `fetch_one` with more than one row
|
|
48
|
+
|
|
49
|
+
A decode mismatch (column type doesn't match the field type) raises on
|
|
50
|
+
the Ruby side rather than becoming a recoverable error — schema drift is
|
|
51
|
+
a programmer bug.
|
|
52
|
+
|
|
53
|
+
## Transactions
|
|
54
|
+
|
|
55
|
+
`Sql.transaction` runs a `Task` inside a single DB transaction on the
|
|
56
|
+
shared AR connection. Every `fetch_*` / `execute` the task performs
|
|
57
|
+
participates in it; the transaction commits on `Ok` and rolls back —
|
|
58
|
+
re-raising the error — on `Err`:
|
|
59
|
+
|
|
60
|
+
```jade
|
|
61
|
+
import Sql exposing (SqlError, execute, transaction)
|
|
62
|
+
|
|
63
|
+
def book(visit: NewVisit, patient: Patient) -> Task(Int, SqlError)
|
|
64
|
+
record_visit(visit)
|
|
65
|
+
|> Task.and_then((_) -> { touch_last_seen(patient) })
|
|
66
|
+
|> transaction
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Because the wrapped task keeps its own decoding, `transaction` is fully
|
|
71
|
+
polymorphic in the result — `transaction(t) : Task(a, SqlError)` for any
|
|
72
|
+
`t : Task(a, SqlError)`.
|
|
73
|
+
|
|
74
|
+
Transactions don't nest yet (no savepoints): wrapping a `transaction`
|
|
75
|
+
inside another issues a second `BEGIN` on the same connection. Needs
|
|
76
|
+
opt-in via `require 'jade-sql/runtime'`.
|
|
77
|
+
|
|
78
|
+
## Testing without a DB
|
|
79
|
+
|
|
80
|
+
The Task dispatcher can be stubbed. From RSpec:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
require 'jade/tasks/rspec'
|
|
84
|
+
|
|
85
|
+
describe MyApp do
|
|
86
|
+
include Jade::Tasks::RSpec
|
|
87
|
+
|
|
88
|
+
it 'queries patients' do
|
|
89
|
+
all_calls_to(JadeSql::Runtime.port_execute_many) do |t, _pair|
|
|
90
|
+
t.ok([
|
|
91
|
+
{ "id" => 1, "name" => "Paul", "mrn" => "MRN-001" }
|
|
92
|
+
])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
expect(MyApp.list.run).to be_ok
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The three ports are `port_execute_count`, `port_execute_one`,
|
|
101
|
+
`port_execute_many` — `Sql.execute*` / `Sql.fetch_*` and their `*_raw`
|
|
102
|
+
siblings ultimately dispatch through these. Stub them with
|
|
103
|
+
`all_calls_to(JadeSql::Runtime.port_execute_*) { |t, pair| ... }`.
|