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 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| ... }`.