jade-sql 0.2.0 → 0.3.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 +4 -4
- data/docs/building.md +31 -27
- data/docs/running.md +3 -0
- data/lib/jade-sql/runtime.rb +35 -3
- data/lib/jade-sql/sql/mutation.jd +36 -1
- data/lib/jade-sql/sql.jd +44 -0
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +6 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06d76c59dbdec2e461a437690981df433898e1366503d665d2b10778fa504d48
|
|
4
|
+
data.tar.gz: 2c608f51a766507d5f9f13df7acce793177d6241e742edf05968eacff8482525
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f87fac54b24c1a530fa20ee750168654ec6c7a20d60dc5f09afb556862a132ae71e3a8c5e1683e041ccd749ca6fd51db3615a04ac6bfc631c03e61fc533fba68
|
|
7
|
+
data.tar.gz: 8e6fe0954616823a3cf8b44da971562a4cf8fbefa50cad20d66604e77c8c016a6b0c12a5c8e3bbfb5e5c72afe10961db4a4b996bf1b08d22ae4a4b4682068faf
|
data/docs/building.md
CHANGED
|
@@ -109,6 +109,23 @@ Notes:
|
|
|
109
109
|
fills one slot in declared order.
|
|
110
110
|
- `to_sql(q)` returns `(String, List(Value))`.
|
|
111
111
|
|
|
112
|
+
### Predicates
|
|
113
|
+
|
|
114
|
+
`eq`, `gt`, `gte`, `lt`, `lte` compare two `Expr(a)` and yield `Expr(Bool)`;
|
|
115
|
+
`is_null` / `is_not_null` take one; `and` joins two; `in_` matches a list.
|
|
116
|
+
`now` is the DB clock (`now()`), for time comparisons:
|
|
117
|
+
|
|
118
|
+
```jade
|
|
119
|
+
import Sql exposing (column, gt, now, to_expr)
|
|
120
|
+
|
|
121
|
+
a.starts_at |> gte(to_expr(cutoff)) # a.starts_at >= ?
|
|
122
|
+
column("s", "expires_at") |> gt(now) # s.expires_at > now()
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`now` is `Expr(Instant)` — the *DB* transaction clock, not the app clock.
|
|
126
|
+
It's the right tool for `WHERE` filters; for `created_at`/`updated_at` use
|
|
127
|
+
`Sql.Mutation.timestamped` (below), which uses the app clock like Rails.
|
|
128
|
+
|
|
112
129
|
### Sorting and grouping
|
|
113
130
|
|
|
114
131
|
`order(q, e)` appends an ASC term, `order_desc(q, e)` a DESC term, and
|
|
@@ -443,38 +460,25 @@ sparse_changes
|
|
|
443
460
|
|
|
444
461
|
### Timestamps
|
|
445
462
|
|
|
446
|
-
`
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
**1. DB-side defaults (recommended).** Let the schema own timestamp
|
|
451
|
-
policy:
|
|
452
|
-
|
|
453
|
-
```sql
|
|
454
|
-
ALTER TABLE patients ALTER COLUMN created_at SET DEFAULT now();
|
|
455
|
-
ALTER TABLE patients ALTER COLUMN updated_at SET DEFAULT now();
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
The mutation builder stays a thin SQL emitter; the DB fills in
|
|
459
|
-
defaults for any column the INSERT didn't list.
|
|
460
|
-
|
|
461
|
-
**2. Explicit injection in app code.** When you want Jade to carry the
|
|
462
|
-
timestamp values (Rails-style), tack assignments onto the list before
|
|
463
|
-
the call:
|
|
463
|
+
`insert`/`update` emit only the columns you set — they don't auto-fill
|
|
464
|
+
`created_at` / `updated_at`. Opt in per-write with `timestamped`, which
|
|
465
|
+
works like ActiveRecord: `created_at` + `updated_at` on insert, `updated_at`
|
|
466
|
+
only on update.
|
|
464
467
|
|
|
465
468
|
```jade
|
|
466
|
-
|
|
467
|
-
-> List(Assignment)
|
|
468
|
-
assigns ++ [assign("created_at", now), assign("updated_at", now)]
|
|
469
|
-
end
|
|
469
|
+
import Sql.Mutation exposing (insert, timestamped, update)
|
|
470
470
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|> insert(_, patients)
|
|
475
|
-
end
|
|
471
|
+
new_patient |> insert(patients) |> timestamped |> execute -- both set
|
|
472
|
+
patient |> update(patients) |> timestamped |> execute -- updated_at only
|
|
473
|
+
new_import |> insert(patients) |> execute -- no timestamps
|
|
476
474
|
```
|
|
477
475
|
|
|
476
|
+
It's opt-in on purpose — backfills, imports, and `touch: false`-style writes
|
|
477
|
+
just omit it (and can set the columns explicitly). The value is the **app
|
|
478
|
+
clock** at execute time (set in Ruby, the same clock Rails uses, so
|
|
479
|
+
`travel_to`/Timecop freeze it), and `created_at` == `updated_at` on an
|
|
480
|
+
insert. No schema changes and no DB-side `DEFAULT` needed.
|
|
481
|
+
|
|
478
482
|
### UUIDs
|
|
479
483
|
|
|
480
484
|
`Sql.Uuid` defines an opaque `Uuid` type plus generation/parse helpers.
|
data/docs/running.md
CHANGED
|
@@ -45,6 +45,9 @@ at the boundary.
|
|
|
45
45
|
- `DbError(String)` — AR `StatementInvalid` message
|
|
46
46
|
- `NotFound` — `fetch_one` with zero rows
|
|
47
47
|
- `NotUnique` — `fetch_one` with more than one row
|
|
48
|
+
- `Conflict(String)` — a write hit a unique index; the `String` is the
|
|
49
|
+
violated constraint name (e.g. `users_email_key`), so you can route it to a
|
|
50
|
+
field error instead of string-matching a `DbError` message
|
|
48
51
|
|
|
49
52
|
A decode mismatch (column type doesn't match the field type) raises on
|
|
50
53
|
the Ruby side rather than becoming a recoverable error — schema drift is
|
data/lib/jade-sql/runtime.rb
CHANGED
|
@@ -12,7 +12,9 @@ module JadeSql
|
|
|
12
12
|
task :port_execute_count do |t, pair|
|
|
13
13
|
sql, params = pair._1, pair._2
|
|
14
14
|
conn = ::ActiveRecord::Base.connection
|
|
15
|
-
t.ok(conn.exec_update(adapt_sql(sql, conn), "Jade", typed_params(params, conn)))
|
|
15
|
+
t.ok(conn.exec_update(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)))
|
|
16
|
+
rescue ::ActiveRecord::RecordNotUnique => e
|
|
17
|
+
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
16
18
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
17
19
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
18
20
|
end
|
|
@@ -20,12 +22,14 @@ module JadeSql
|
|
|
20
22
|
task :port_execute_one do |t, pair|
|
|
21
23
|
sql, params = pair._1, pair._2
|
|
22
24
|
conn = ::ActiveRecord::Base.connection
|
|
23
|
-
rows = conn.exec_query(adapt_sql(sql, conn), "Jade", typed_params(params, conn)).to_a
|
|
25
|
+
rows = conn.exec_query(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
|
|
24
26
|
case rows.length
|
|
25
27
|
when 0 then t.err(JadeSql::SqlErrors.not_found)
|
|
26
28
|
when 1 then t.ok(coerce_row(rows.first))
|
|
27
29
|
else t.err(JadeSql::SqlErrors.not_unique)
|
|
28
30
|
end
|
|
31
|
+
rescue ::ActiveRecord::RecordNotUnique => e
|
|
32
|
+
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
29
33
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
30
34
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
31
35
|
end
|
|
@@ -33,8 +37,10 @@ module JadeSql
|
|
|
33
37
|
task :port_execute_many do |t, pair|
|
|
34
38
|
sql, params = pair._1, pair._2
|
|
35
39
|
conn = ::ActiveRecord::Base.connection
|
|
36
|
-
rows = conn.exec_query(adapt_sql(sql, conn), "Jade", typed_params(params, conn)).to_a
|
|
40
|
+
rows = conn.exec_query(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
|
|
37
41
|
t.ok(rows.map { |row| coerce_row(row) })
|
|
42
|
+
rescue ::ActiveRecord::RecordNotUnique => e
|
|
43
|
+
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
38
44
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
39
45
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
40
46
|
end
|
|
@@ -160,6 +166,32 @@ module JadeSql
|
|
|
160
166
|
raw == "NULL" ? nil : raw
|
|
161
167
|
end
|
|
162
168
|
|
|
169
|
+
# The constraint/index name behind a RecordNotUnique, so callers can route
|
|
170
|
+
# by which unique index was violated. PG reports it in the error's
|
|
171
|
+
# diagnostics; other adapters (or a missing name) fall back to "".
|
|
172
|
+
def self.constraint_name(error)
|
|
173
|
+
cause = error.cause
|
|
174
|
+
return "" unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
|
|
175
|
+
|
|
176
|
+
cause.result.error_field(::PG::Result::PG_DIAG_CONSTRAINT_NAME) || ""
|
|
177
|
+
rescue StandardError
|
|
178
|
+
""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Sql.Mutation.timestamped emits "$JADE_SQL_NOW$" where created_at /
|
|
182
|
+
# updated_at go. Swap it for one UTC timestamp literal per statement,
|
|
183
|
+
# computed here — the app clock, so it moves with travel_to/Timecop
|
|
184
|
+
# (unlike DB now()). The token lives in SQL we generate, never in a
|
|
185
|
+
# bound value, so it can't collide with user data.
|
|
186
|
+
NOW_TOKEN = "$JADE_SQL_NOW$"
|
|
187
|
+
|
|
188
|
+
def self.fill_now(sql)
|
|
189
|
+
return sql unless sql.include?(NOW_TOKEN)
|
|
190
|
+
|
|
191
|
+
stamp = "'#{::Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%6N+00')}'"
|
|
192
|
+
sql.gsub(NOW_TOKEN) { stamp }
|
|
193
|
+
end
|
|
194
|
+
|
|
163
195
|
# Sql renders `?` placeholders uniformly. AR's exec_query/exec_update
|
|
164
196
|
# path on the PG adapter expects `$1, $2, …` — there is no `?`-to-`$n`
|
|
165
197
|
# rewrite at that layer. SQLite and MySQL accept `?` directly, so this
|
|
@@ -5,13 +5,14 @@ module Sql.Mutation exposing (
|
|
|
5
5
|
insert,
|
|
6
6
|
insert_all,
|
|
7
7
|
returning,
|
|
8
|
+
timestamped,
|
|
8
9
|
to_sql,
|
|
9
10
|
update,
|
|
10
11
|
update_all,
|
|
11
12
|
)
|
|
12
13
|
|
|
13
14
|
import Sql exposing (
|
|
14
|
-
Assignment,
|
|
15
|
+
Assignment(..),
|
|
15
16
|
Expr(..),
|
|
16
17
|
Renderable,
|
|
17
18
|
Selector(..),
|
|
@@ -132,6 +133,40 @@ def returning(m: Mutation(a, c), build: c -> Q(Selector(b))) -> Mutation(b, c)
|
|
|
132
133
|
end
|
|
133
134
|
|
|
134
135
|
|
|
136
|
+
# Opt-in ActiveRecord-style timestamps: fills created_at + updated_at on
|
|
137
|
+
# insert and updated_at on update, with the app clock at execute time.
|
|
138
|
+
# The "$JADE_SQL_NOW$" token carries no param; the runtime swaps it for a
|
|
139
|
+
# single UTC timestamp literal per statement (so created_at == updated_at).
|
|
140
|
+
# A plain `insert`/`update` without this stays timestamp-free.
|
|
141
|
+
def timestamped(m: Mutation(ret, c)) -> Mutation(ret, c)
|
|
142
|
+
case m.kind
|
|
143
|
+
in InsertK then with_rows(m, List.map(m.rows, append_insert_stamps))
|
|
144
|
+
in UpdateK then with_rows(m, List.map(m.rows, append_update_stamps))
|
|
145
|
+
in DeleteK then m
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def append_insert_stamps(row: List(Assignment)) -> List(Assignment)
|
|
151
|
+
row ++ [now_assignment("created_at"), now_assignment("updated_at")]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def append_update_stamps(row: List(Assignment)) -> List(Assignment)
|
|
156
|
+
row ++ [now_assignment("updated_at")]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def now_assignment(col: String) -> Assignment
|
|
161
|
+
Assignment(col, "$JADE_SQL_NOW$", [])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def with_rows(m: Mutation(ret, c), rows: List(List(Assignment))) -> Mutation(ret, c)
|
|
166
|
+
Mutation(m.kind, m.table, m.cols, rows, m.wheres, m.returning_selector)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
135
170
|
def pk_predicate(cols: List(String), values: List(Value)) -> Expr(Bool)
|
|
136
171
|
sql = cols
|
|
137
172
|
|> List.map((col) -> { col ++ " = ?" })
|
data/lib/jade-sql/sql.jd
CHANGED
|
@@ -32,13 +32,18 @@ module Sql exposing (
|
|
|
32
32
|
fetch_many_raw,
|
|
33
33
|
fetch_one,
|
|
34
34
|
fetch_one_raw,
|
|
35
|
+
gt,
|
|
36
|
+
gte,
|
|
35
37
|
in_,
|
|
36
38
|
is_not_null,
|
|
37
39
|
is_null,
|
|
38
40
|
jsonb_contains,
|
|
39
41
|
jsonb_path_exists,
|
|
42
|
+
lt,
|
|
43
|
+
lte,
|
|
40
44
|
maybe_columns,
|
|
41
45
|
neg,
|
|
46
|
+
now,
|
|
42
47
|
nullable,
|
|
43
48
|
pk_values,
|
|
44
49
|
render,
|
|
@@ -52,6 +57,7 @@ module Sql exposing (
|
|
|
52
57
|
|
|
53
58
|
import Encode exposing (Encodable, encode)
|
|
54
59
|
import Decode exposing (Decodable, Decoder, Value)
|
|
60
|
+
import Clock exposing (Instant)
|
|
55
61
|
|
|
56
62
|
|
|
57
63
|
struct Expr(a) = {
|
|
@@ -139,11 +145,38 @@ def to_expr(value: a) -> Expr(a)
|
|
|
139
145
|
end
|
|
140
146
|
|
|
141
147
|
|
|
148
|
+
# The DB clock (Postgres `now()`), for `where(col |> gt(now))`-style
|
|
149
|
+
# comparisons. This is the transaction timestamp, not the app clock.
|
|
150
|
+
def now -> Expr(Instant)
|
|
151
|
+
Expr("now()", [])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
|
|
142
155
|
def eq(left: Expr(a), right: Expr(a)) -> Expr(Bool)
|
|
143
156
|
Expr(left.sql ++ " = " ++ right.sql, left.params ++ right.params)
|
|
144
157
|
end
|
|
145
158
|
|
|
146
159
|
|
|
160
|
+
def gt(left: Expr(a), right: Expr(a)) -> Expr(Bool)
|
|
161
|
+
Expr(left.sql ++ " > " ++ right.sql, left.params ++ right.params)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def gte(left: Expr(a), right: Expr(a)) -> Expr(Bool)
|
|
166
|
+
Expr(left.sql ++ " >= " ++ right.sql, left.params ++ right.params)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def lt(left: Expr(a), right: Expr(a)) -> Expr(Bool)
|
|
171
|
+
Expr(left.sql ++ " < " ++ right.sql, left.params ++ right.params)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def lte(left: Expr(a), right: Expr(a)) -> Expr(Bool)
|
|
176
|
+
Expr(left.sql ++ " <= " ++ right.sql, left.params ++ right.params)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
|
|
147
180
|
def is_null(e: Expr(a)) -> Expr(Bool)
|
|
148
181
|
Expr(e.sql ++ " IS NULL", e.params)
|
|
149
182
|
end
|
|
@@ -320,10 +353,14 @@ def aliased(t: Table(c, m), alias_: String) -> Table(c, m)
|
|
|
320
353
|
end
|
|
321
354
|
|
|
322
355
|
|
|
356
|
+
# `NotUnique` is a read-side failure (fetch_one saw more than one row).
|
|
357
|
+
# `Conflict` is a write-side unique-index violation, carrying the violated
|
|
358
|
+
# constraint name so callers route it to a field error without string-matching.
|
|
323
359
|
type SqlError
|
|
324
360
|
= DbError(String)
|
|
325
361
|
| NotFound
|
|
326
362
|
| NotUnique
|
|
363
|
+
| Conflict(String)
|
|
327
364
|
|
|
328
365
|
|
|
329
366
|
implements Encodable(SqlError) with
|
|
@@ -336,6 +373,7 @@ def encode_sql_error(e: SqlError) -> Value
|
|
|
336
373
|
in DbError(msg) then Encode.variant("DbError", [Encode.string(msg)])
|
|
337
374
|
in NotFound then Encode.variant("NotFound", [])
|
|
338
375
|
in NotUnique then Encode.variant("NotUnique", [])
|
|
376
|
+
in Conflict(name) then Encode.variant("Conflict", [Encode.string(name)])
|
|
339
377
|
end
|
|
340
378
|
end
|
|
341
379
|
|
|
@@ -350,6 +388,7 @@ def sql_error_decoder -> Decoder(SqlError)
|
|
|
350
388
|
|> Decode.variant("DbError", db_error_decoder)
|
|
351
389
|
|> Decode.variant("NotFound", Decode.succeed(NotFound))
|
|
352
390
|
|> Decode.variant("NotUnique", Decode.succeed(NotUnique))
|
|
391
|
+
|> Decode.variant("Conflict", conflict_decoder)
|
|
353
392
|
end
|
|
354
393
|
|
|
355
394
|
|
|
@@ -358,6 +397,11 @@ def db_error_decoder -> Decoder(SqlError)
|
|
|
358
397
|
end
|
|
359
398
|
|
|
360
399
|
|
|
400
|
+
def conflict_decoder -> Decoder(SqlError)
|
|
401
|
+
Decode.index(1, Decode.string) |> Decode.map(Conflict)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
|
|
361
405
|
uses JadeSql::Runtime with
|
|
362
406
|
port_execute_count : (String, List(Value)) -> Task(Int, SqlError),
|
|
363
407
|
port_execute_one : (String, List(Value)) -> Task(a, SqlError),
|
data/lib/jade-sql/version.rb
CHANGED
data/lib/jade-sql.rb
CHANGED
|
@@ -14,9 +14,10 @@ module JadeSql
|
|
|
14
14
|
NOT_FOUND = ["NotFound"].freeze
|
|
15
15
|
NOT_UNIQUE = ["NotUnique"].freeze
|
|
16
16
|
|
|
17
|
-
def self.db_error(msg)
|
|
18
|
-
def self.not_found
|
|
19
|
-
def self.not_unique
|
|
17
|
+
def self.db_error(msg) = ["DbError", msg]
|
|
18
|
+
def self.not_found = NOT_FOUND
|
|
19
|
+
def self.not_unique = NOT_UNIQUE
|
|
20
|
+
def self.conflict(name) = ["Conflict", name]
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -26,11 +27,13 @@ module Sql
|
|
|
26
27
|
class DbError < Error; end
|
|
27
28
|
class NotFound < Error; end
|
|
28
29
|
class NotUnique < Error; end
|
|
30
|
+
class Conflict < Error; end
|
|
29
31
|
|
|
30
32
|
BY_TAG = {
|
|
31
33
|
"DbError" => DbError,
|
|
32
34
|
"NotFound" => NotFound,
|
|
33
35
|
"NotUnique" => NotUnique,
|
|
36
|
+
"Conflict" => Conflict,
|
|
34
37
|
}.freeze
|
|
35
38
|
end
|
|
36
39
|
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- agustin
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
10
|
+
date: 2026-06-17 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: jade-lang
|