jade-sql 0.2.0 → 0.4.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 +43 -43
- data/docs/running.md +3 -0
- data/lib/jade-sql/bin/generate_schema.rb +1 -1
- data/lib/jade-sql/runtime.rb +43 -11
- 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 +4 -5
- data/lib/jade-sql/sql/decimal.jd +0 -193
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 203373e2a0789908e800cda5e81acee5883edb436205ec016f281549451218cd
|
|
4
|
+
data.tar.gz: 3338e7d2e8132fdf4ed4de0e8b53658d8a2cce64ee8f13394cc7dca53413906c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5e68d85a8d74169946d1368aa91ef46fe99c05a6b3250a5f11b44cc0ee13f75c66b3e1a3bbe59febb9c63ebee3f32ea419396444dffb699fa239a211f943821
|
|
7
|
+
data.tar.gz: 4ad41bf4b0b780a78b599bba48bd90f7b14a35dfd2564a38b8153bbeec8dd8a7abd77a08d6c43db9888df49096429702aa31d2a6ceebd606446023d729988dd7
|
data/docs/building.md
CHANGED
|
@@ -28,22 +28,18 @@ bundle exec rake jade:schema \
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Type map: `bigint`/`integer`/`smallint` → `Int`, `numeric`/`decimal` →
|
|
31
|
-
`Decimal` (
|
|
32
|
-
`varchar`/`text`/`char` → `String`, `boolean` → `Bool`,
|
|
33
|
-
`Decode.Value`, `date` → `Calendar.Date`, `timestamp` →
|
|
34
|
-
`uuid` → `Uuid` (from `Sql.Uuid`). Unknown types fail
|
|
35
|
-
table+column name.
|
|
36
|
-
|
|
37
|
-
`numeric`/`decimal` map to `
|
|
38
|
-
(`
|
|
39
|
-
floating-point columns (`double precision`/`real`) map to `Float`.
|
|
40
|
-
isn't mapped yet, though jade's `Bytes` is the natural target.
|
|
41
|
-
|
|
42
|
-
`
|
|
43
|
-
Mirroring `BigDecimal`, `/` can't represent a repeating quotient, so it
|
|
44
|
-
rounds half-up to a default scale (use `div(a, b, scale)` for explicit
|
|
45
|
-
control, `round(d, scale)` to round); division by zero raises. `to_i`
|
|
46
|
-
truncates toward zero and `to_float` converts (lossily, on purpose).
|
|
31
|
+
`Decimal` (jade's stdlib exact decimal), `double precision`/`real` →
|
|
32
|
+
`Float`, `varchar`/`text`/`char` → `String`, `boolean` → `Bool`,
|
|
33
|
+
`jsonb`/`json` → `Decode.Value`, `date` → `Calendar.Date`, `timestamp` →
|
|
34
|
+
`Clock.Instant`, `uuid` → `Uuid` (from `Sql.Uuid`). Unknown types fail
|
|
35
|
+
loudly with the table+column name.
|
|
36
|
+
|
|
37
|
+
`numeric`/`decimal` map to the stdlib `Decimal` — an exact base-10 value
|
|
38
|
+
(`coefficient * 10^exponent`), never `Float`, so no precision is lost.
|
|
39
|
+
Genuine floating-point columns (`double precision`/`real`) map to `Float`.
|
|
40
|
+
`bytea` isn't mapped yet, though jade's `Bytes` is the natural target. See
|
|
41
|
+
jade-lang's `Decimal` for the full API (`of`/`scaled`/`parse`, arithmetic,
|
|
42
|
+
`round`, `to_i`/`to_float`).
|
|
47
43
|
|
|
48
44
|
For each table, the generator emits:
|
|
49
45
|
|
|
@@ -109,6 +105,23 @@ Notes:
|
|
|
109
105
|
fills one slot in declared order.
|
|
110
106
|
- `to_sql(q)` returns `(String, List(Value))`.
|
|
111
107
|
|
|
108
|
+
### Predicates
|
|
109
|
+
|
|
110
|
+
`eq`, `gt`, `gte`, `lt`, `lte` compare two `Expr(a)` and yield `Expr(Bool)`;
|
|
111
|
+
`is_null` / `is_not_null` take one; `and` joins two; `in_` matches a list.
|
|
112
|
+
`now` is the DB clock (`now()`), for time comparisons:
|
|
113
|
+
|
|
114
|
+
```jade
|
|
115
|
+
import Sql exposing (column, gt, now, to_expr)
|
|
116
|
+
|
|
117
|
+
a.starts_at |> gte(to_expr(cutoff)) # a.starts_at >= ?
|
|
118
|
+
column("s", "expires_at") |> gt(now) # s.expires_at > now()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`now` is `Expr(Instant)` — the *DB* transaction clock, not the app clock.
|
|
122
|
+
It's the right tool for `WHERE` filters; for `created_at`/`updated_at` use
|
|
123
|
+
`Sql.Mutation.timestamped` (below), which uses the app clock like Rails.
|
|
124
|
+
|
|
112
125
|
### Sorting and grouping
|
|
113
126
|
|
|
114
127
|
`order(q, e)` appends an ASC term, `order_desc(q, e)` a DESC term, and
|
|
@@ -443,38 +456,25 @@ sparse_changes
|
|
|
443
456
|
|
|
444
457
|
### Timestamps
|
|
445
458
|
|
|
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:
|
|
459
|
+
`insert`/`update` emit only the columns you set — they don't auto-fill
|
|
460
|
+
`created_at` / `updated_at`. Opt in per-write with `timestamped`, which
|
|
461
|
+
works like ActiveRecord: `created_at` + `updated_at` on insert, `updated_at`
|
|
462
|
+
only on update.
|
|
464
463
|
|
|
465
464
|
```jade
|
|
466
|
-
|
|
467
|
-
-> List(Assignment)
|
|
468
|
-
assigns ++ [assign("created_at", now), assign("updated_at", now)]
|
|
469
|
-
end
|
|
465
|
+
import Sql.Mutation exposing (insert, timestamped, update)
|
|
470
466
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|> insert(_, patients)
|
|
475
|
-
end
|
|
467
|
+
new_patient |> insert(patients) |> timestamped |> execute -- both set
|
|
468
|
+
patient |> update(patients) |> timestamped |> execute -- updated_at only
|
|
469
|
+
new_import |> insert(patients) |> execute -- no timestamps
|
|
476
470
|
```
|
|
477
471
|
|
|
472
|
+
It's opt-in on purpose — backfills, imports, and `touch: false`-style writes
|
|
473
|
+
just omit it (and can set the columns explicitly). The value is the **app
|
|
474
|
+
clock** at execute time (set in Ruby, the same clock Rails uses, so
|
|
475
|
+
`travel_to`/Timecop freeze it), and `created_at` == `updated_at` on an
|
|
476
|
+
insert. No schema changes and no DB-side `DEFAULT` needed.
|
|
477
|
+
|
|
478
478
|
### UUIDs
|
|
479
479
|
|
|
480
480
|
`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
|
|
@@ -53,7 +53,7 @@ module JadeSql
|
|
|
53
53
|
"Clock.Instant" => "import Clock",
|
|
54
54
|
"Decode.Value" => "import Decode",
|
|
55
55
|
"Uuid" => "import Sql.Uuid exposing(Uuid)",
|
|
56
|
-
"Decimal" => "import
|
|
56
|
+
"Decimal" => "import Decimal exposing(Decimal)",
|
|
57
57
|
}.freeze
|
|
58
58
|
|
|
59
59
|
# Column names that collide with Jade keywords get a trailing underscore
|
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
|
|
@@ -76,8 +82,8 @@ module JadeSql
|
|
|
76
82
|
# other List(a) column.
|
|
77
83
|
#
|
|
78
84
|
# numeric/decimal columns come back as ::BigDecimal; the schema generator
|
|
79
|
-
# maps them to
|
|
80
|
-
# "<
|
|
85
|
+
# maps them to jade's stdlib Decimal, whose decoder reads the exact
|
|
86
|
+
# "<coefficient>e<exponent>" wire form. Float would lose precision, so don't.
|
|
81
87
|
def self.coerce_row(row)
|
|
82
88
|
row.transform_values { |v| coerce_value(v) }
|
|
83
89
|
end
|
|
@@ -93,19 +99,19 @@ module JadeSql
|
|
|
93
99
|
end
|
|
94
100
|
end
|
|
95
101
|
|
|
96
|
-
# ::BigDecimal -> "<
|
|
102
|
+
# ::BigDecimal -> "<coefficient>e<exponent>" with value = coeff * 10^exp,
|
|
97
103
|
# exactly (BigDecimal#split gives sign, significant digits, and a base-10
|
|
98
|
-
# exponent). Matches the wire form
|
|
104
|
+
# exponent). Matches the wire form jade's stdlib Decimal decoder parses.
|
|
99
105
|
#
|
|
100
|
-
# 'NaN'/'Infinity'::numeric are legal Postgres values that
|
|
101
|
-
#
|
|
106
|
+
# 'NaN'/'Infinity'::numeric are legal Postgres values that Decimal can't
|
|
107
|
+
# represent; fail loudly rather than silently decode them as 0.
|
|
102
108
|
def self.decimal_wire(v)
|
|
103
109
|
raise ArgumentError, "non-finite numeric: #{v}" unless v.finite?
|
|
104
110
|
|
|
105
111
|
sign, digits, _base, exp = v.split
|
|
106
|
-
|
|
112
|
+
coefficient = sign * digits.to_i
|
|
107
113
|
exponent = exp - digits.length
|
|
108
|
-
"#{
|
|
114
|
+
"#{coefficient}e#{exponent}"
|
|
109
115
|
end
|
|
110
116
|
|
|
111
117
|
# PG arrays render as `{}`, `{a,b,c}`, `{"a,b","c"}`, with NULL as
|
|
@@ -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.4.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
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.2.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.2.0
|
|
26
26
|
description: Query and mutation builders, schema generation from db/structure.sql,
|
|
27
27
|
and an ActiveRecord-backed runtime for the Jade language. Renders typed queries
|
|
28
28
|
to (String, List(Value)) and decodes rows into Jade structs.
|
|
@@ -42,7 +42,6 @@ files:
|
|
|
42
42
|
- lib/jade-sql/bin/generate_schema.rb
|
|
43
43
|
- lib/jade-sql/runtime.rb
|
|
44
44
|
- lib/jade-sql/sql.jd
|
|
45
|
-
- lib/jade-sql/sql/decimal.jd
|
|
46
45
|
- lib/jade-sql/sql/loader.jd
|
|
47
46
|
- lib/jade-sql/sql/mutation.jd
|
|
48
47
|
- lib/jade-sql/sql/query.jd
|
data/lib/jade-sql/sql/decimal.jd
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
module Sql.Decimal exposing (
|
|
2
|
-
Decimal,
|
|
3
|
-
decimal,
|
|
4
|
-
div,
|
|
5
|
-
exponent,
|
|
6
|
-
mantissa,
|
|
7
|
-
parse,
|
|
8
|
-
round,
|
|
9
|
-
to_float,
|
|
10
|
-
to_i,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
import Decode exposing (Decodable, Decoder)
|
|
14
|
-
import Encode exposing (Encodable)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# An exact base-10 decimal: value = mantissa * 10 ^ exponent. Lossless for
|
|
18
|
-
# Postgres numeric/decimal — no Float rounding. The wire form is
|
|
19
|
-
# "<mantissa>e<exponent>" (e.g. "175e-3"), which Postgres casts to numeric
|
|
20
|
-
# exactly and which decodes back without parsing a decimal point.
|
|
21
|
-
type Decimal = Decimal(Int, Int)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def decimal(m: Int, e: Int) -> Decimal
|
|
25
|
-
Decimal(m, e)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def mantissa(d: Decimal) -> Int
|
|
30
|
-
Decimal(m, _) = d
|
|
31
|
-
|
|
32
|
-
m
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def exponent(d: Decimal) -> Int
|
|
37
|
-
Decimal(_, e) = d
|
|
38
|
-
|
|
39
|
-
e
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def pow10(n: Int) -> Int
|
|
44
|
-
n <= 0 ? 1 : 10 * pow10(n - 1)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def abs(x: Int) -> Int
|
|
49
|
-
x < 0 ? 0 - x : x
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# Integer division, rounding ties half away from zero — i.e. Java's HALF_UP
|
|
54
|
-
# and Ruby BigDecimal's default mode (2.5 -> 3, -2.5 -> -3). Rounds the
|
|
55
|
-
# magnitude, then re-applies the sign. A zero `den` raises through Int `/`.
|
|
56
|
-
def round_div(num: Int, den: Int) -> Int
|
|
57
|
-
negative = not ((num < 0) == (den < 0))
|
|
58
|
-
n = abs(num)
|
|
59
|
-
d = abs(den)
|
|
60
|
-
q = n / d
|
|
61
|
-
r = n - q * d
|
|
62
|
-
rounded = (2 * r) >= d ? q + 1 : q
|
|
63
|
-
|
|
64
|
-
negative ? 0 - rounded : rounded
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def trunc_div(num: Int, den: Int) -> Int
|
|
69
|
-
q = abs(num) / abs(den)
|
|
70
|
-
|
|
71
|
-
num < 0 ? 0 - q : q
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# `+` `-` `*` are exact. `+`/`-` line the operands up on the smaller exponent
|
|
76
|
-
# first; `*` adds exponents.
|
|
77
|
-
def add(a: Decimal, b: Decimal) -> Decimal
|
|
78
|
-
Decimal(ma, ea) = a
|
|
79
|
-
Decimal(mb, eb) = b
|
|
80
|
-
e = ea < eb ? ea : eb
|
|
81
|
-
|
|
82
|
-
Decimal((ma * pow10(ea - e)) + (mb * pow10(eb - e)), e)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def sub(a: Decimal, b: Decimal) -> Decimal
|
|
87
|
-
Decimal(ma, ea) = a
|
|
88
|
-
Decimal(mb, eb) = b
|
|
89
|
-
e = ea < eb ? ea : eb
|
|
90
|
-
|
|
91
|
-
Decimal((ma * pow10(ea - e)) - (mb * pow10(eb - e)), e)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def mul(a: Decimal, b: Decimal) -> Decimal
|
|
96
|
-
Decimal(ma, ea) = a
|
|
97
|
-
Decimal(mb, eb) = b
|
|
98
|
-
|
|
99
|
-
Decimal(ma * mb, ea + eb)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# a / b rounded half-up to `scale` decimal places — like Java's
|
|
104
|
-
# BigDecimal.divide(divisor, scale, HALF_UP). Division by zero raises.
|
|
105
|
-
def div(a: Decimal, b: Decimal, scale: Int) -> Decimal
|
|
106
|
-
Decimal(ma, ea) = a
|
|
107
|
-
Decimal(mb, eb) = b
|
|
108
|
-
k = (ea - eb) + scale
|
|
109
|
-
num = k >= 0 ? ma * pow10(k) : ma
|
|
110
|
-
den = k >= 0 ? mb : mb * pow10(0 - k)
|
|
111
|
-
|
|
112
|
-
Decimal(round_div(num, den), 0 - scale)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# The Numeric `/`: like Ruby's BigDecimal `/`, it can't be exact for a
|
|
117
|
-
# repeating quotient (1/3), so it rounds half-up to a generous default scale.
|
|
118
|
-
def divide(a: Decimal, b: Decimal) -> Decimal
|
|
119
|
-
div(a, b, 32)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
implements Numeric(Decimal) with
|
|
124
|
-
(+): add,
|
|
125
|
-
(-): sub,
|
|
126
|
-
(*): mul,
|
|
127
|
-
(/): divide
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# Round to `scale` decimal places, half-up. Fewer places than the value
|
|
132
|
-
# already has are left untouched.
|
|
133
|
-
def round(d: Decimal, scale: Int) -> Decimal
|
|
134
|
-
Decimal(m, e) = d
|
|
135
|
-
shift = e + scale
|
|
136
|
-
|
|
137
|
-
shift >= 0 ? d : Decimal(round_div(m, pow10(0 - shift)), 0 - scale)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# Truncates toward zero (drops the fractional part).
|
|
142
|
-
def to_i(d: Decimal) -> Int
|
|
143
|
-
Decimal(m, e) = d
|
|
144
|
-
|
|
145
|
-
e >= 0 ? m * pow10(e) : trunc_div(m, pow10(0 - e))
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def to_float(d: Decimal) -> Float
|
|
150
|
-
Decimal(m, e) = d
|
|
151
|
-
|
|
152
|
-
e >= 0
|
|
153
|
-
? Basics.to_float(m * pow10(e))
|
|
154
|
-
: Basics.to_float(m) / Basics.to_float(pow10(0 - e))
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def to_wire(d: Decimal) -> String
|
|
159
|
-
Decimal(m, e) = d
|
|
160
|
-
|
|
161
|
-
String.from_int(m) ++ "e" ++ String.from_int(e)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def parse(s: String) -> Maybe(Decimal)
|
|
166
|
-
case String.split(s, "e")
|
|
167
|
-
in [m, e]
|
|
168
|
-
String.to_int(m)
|
|
169
|
-
|> Maybe.and_then((mi) -> {
|
|
170
|
-
String.to_int(e) |> Maybe.map((ei) -> { Decimal(mi, ei) })
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
else Nothing
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def decimal_decoder(s: String) -> Decoder(Decimal)
|
|
179
|
-
case parse(s)
|
|
180
|
-
in Just(d) then Decode.succeed(d)
|
|
181
|
-
in Nothing then Decode.fail("invalid decimal: " ++ s)
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
implements Decodable(Decimal) with
|
|
187
|
-
decoder: -> { Decode.string |> Decode.and_then(decimal_decoder) }
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
implements Encodable(Decimal) with
|
|
192
|
-
encoder: (d) -> { Encode.string(to_wire(d)) }
|
|
193
|
-
end
|