jade-sql 0.1.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/README.md +2 -0
- data/docs/building.md +66 -34
- data/docs/running.md +3 -0
- data/lib/jade-sql/bin/generate_schema.rb +75 -20
- data/lib/jade-sql/runtime.rb +56 -3
- data/lib/jade-sql/sql/decimal.jd +193 -0
- data/lib/jade-sql/sql/mutation.jd +36 -1
- data/lib/jade-sql/sql/query.jd +18 -0
- data/lib/jade-sql/sql.jd +44 -0
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +6 -3
- metadata +3 -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/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# jade-sql
|
|
2
2
|
|
|
3
|
+
[](https://github.com/agustinrhcp/jade-sql/actions/workflows/ci.yml)
|
|
4
|
+
|
|
3
5
|
Type-safe SQL for Jade. Builds queries and mutations from a generated
|
|
4
6
|
schema, renders them to `(String, List(Value))`, and runs them against
|
|
5
7
|
ActiveRecord via a Task port that auto-decodes rows into typed Jade
|
data/docs/building.md
CHANGED
|
@@ -27,13 +27,23 @@ bundle exec rake jade:schema \
|
|
|
27
27
|
OUTPUT=app/jade/schema/billing.jd
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Type map: `bigint`/`integer`/`smallint` → `Int`, `
|
|
31
|
-
`
|
|
32
|
-
`
|
|
33
|
-
`
|
|
34
|
-
`
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
Type map: `bigint`/`integer`/`smallint` → `Int`, `numeric`/`decimal` →
|
|
31
|
+
`Decimal` (from `Sql.Decimal`), `double precision`/`real` → `Float`,
|
|
32
|
+
`varchar`/`text`/`char` → `String`, `boolean` → `Bool`, `jsonb`/`json` →
|
|
33
|
+
`Decode.Value`, `date` → `Calendar.Date`, `timestamp` → `Clock.Instant`,
|
|
34
|
+
`uuid` → `Uuid` (from `Sql.Uuid`). Unknown types fail loudly with the
|
|
35
|
+
table+column name.
|
|
36
|
+
|
|
37
|
+
`numeric`/`decimal` map to `Sql.Decimal` — an exact base-10 value
|
|
38
|
+
(`mantissa * 10^exponent`), never `Float`, so no precision is lost. Genuine
|
|
39
|
+
floating-point columns (`double precision`/`real`) map to `Float`. `bytea`
|
|
40
|
+
isn't mapped yet, though jade's `Bytes` is the natural target.
|
|
41
|
+
|
|
42
|
+
`Sql.Decimal` implements `Numeric`, so `+`/`-`/`*` work and stay exact.
|
|
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).
|
|
37
47
|
|
|
38
48
|
For each table, the generator emits:
|
|
39
49
|
|
|
@@ -51,6 +61,24 @@ Strict cols mirror NOT NULL constraints; the maybe version wraps every
|
|
|
51
61
|
field in `Maybe` for left-join projections. The default alias is the
|
|
52
62
|
table name; override per-call with `aliased` (see joins below).
|
|
53
63
|
|
|
64
|
+
A column whose name is a Jade keyword (e.g. `type`) gets a trailing
|
|
65
|
+
underscore in the struct field (`type_`) while the SQL column reference
|
|
66
|
+
keeps the real name. For a table with such a column the generator also
|
|
67
|
+
emits a `<table>_row` projector that aliases every column to its field name
|
|
68
|
+
(`SELECT … AS type_`), so reads round-trip without hand-written SQL:
|
|
69
|
+
|
|
70
|
+
```jade
|
|
71
|
+
def entries -> Q(Selector(JournalEntriesRow))
|
|
72
|
+
c <- from(journal_entries)
|
|
73
|
+
journal_entries_row(c)
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
In hand-written selects, `field_as(e, "name")` sets a column's output name
|
|
78
|
+
when it differs from the SQL — needed for renamed columns and computed
|
|
79
|
+
projections (decode keys by field name, so `field_as(count_all, "visits")`
|
|
80
|
+
makes a `COUNT(*)` land in a `visits` field).
|
|
81
|
+
|
|
54
82
|
## Build queries
|
|
55
83
|
|
|
56
84
|
```jade
|
|
@@ -81,6 +109,23 @@ Notes:
|
|
|
81
109
|
fills one slot in declared order.
|
|
82
110
|
- `to_sql(q)` returns `(String, List(Value))`.
|
|
83
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
|
+
|
|
84
129
|
### Sorting and grouping
|
|
85
130
|
|
|
86
131
|
`order(q, e)` appends an ASC term, `order_desc(q, e)` a DESC term, and
|
|
@@ -415,38 +460,25 @@ sparse_changes
|
|
|
415
460
|
|
|
416
461
|
### Timestamps
|
|
417
462
|
|
|
418
|
-
`
|
|
419
|
-
|
|
420
|
-
|
|
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:
|
|
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.
|
|
436
467
|
|
|
437
468
|
```jade
|
|
438
|
-
|
|
439
|
-
-> List(Assignment)
|
|
440
|
-
assigns ++ [assign("created_at", now), assign("updated_at", now)]
|
|
441
|
-
end
|
|
469
|
+
import Sql.Mutation exposing (insert, timestamped, update)
|
|
442
470
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|> insert(_, patients)
|
|
447
|
-
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
|
|
448
474
|
```
|
|
449
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
|
+
|
|
450
482
|
### UUIDs
|
|
451
483
|
|
|
452
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
|
|
@@ -23,10 +23,18 @@ module JadeSql
|
|
|
23
23
|
/\Adate\[\]/ => "List(Calendar.Date)",
|
|
24
24
|
/\Atimestamp\[\]/ => "List(Clock.Instant)",
|
|
25
25
|
/\Auuid\[\]/ => "List(Uuid)",
|
|
26
|
+
/\Anumeric\[\]/ => "List(Decimal)",
|
|
27
|
+
/\Adecimal\[\]/ => "List(Decimal)",
|
|
28
|
+
/\Adouble precision\[\]/ => "List(Float)",
|
|
29
|
+
/\Areal\[\]/ => "List(Float)",
|
|
26
30
|
|
|
27
31
|
/\Abigint\b/ => "Int",
|
|
28
32
|
/\Ainteger\b/ => "Int",
|
|
29
33
|
/\Asmallint\b/ => "Int",
|
|
34
|
+
/\Anumeric\b/ => "Decimal",
|
|
35
|
+
/\Adecimal\b/ => "Decimal",
|
|
36
|
+
/\Adouble precision\b/ => "Float",
|
|
37
|
+
/\Areal\b/ => "Float",
|
|
30
38
|
/\Acharacter varying\b/ => "String",
|
|
31
39
|
/\Avarchar\b/ => "String",
|
|
32
40
|
/\Acharacter\b/ => "String",
|
|
@@ -45,16 +53,24 @@ module JadeSql
|
|
|
45
53
|
"Clock.Instant" => "import Clock",
|
|
46
54
|
"Decode.Value" => "import Decode",
|
|
47
55
|
"Uuid" => "import Sql.Uuid exposing(Uuid)",
|
|
56
|
+
"Decimal" => "import Sql.Decimal exposing(Decimal)",
|
|
48
57
|
}.freeze
|
|
49
58
|
|
|
59
|
+
# Column names that collide with Jade keywords get a trailing underscore
|
|
60
|
+
# in the struct field; the SQL column reference keeps the real name.
|
|
61
|
+
RESERVED = %w[
|
|
62
|
+
type def module import exposing struct interface implements uses
|
|
63
|
+
case in if then else end with
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
50
66
|
Table = Data.define(:name, :columns, :pk_columns)
|
|
51
67
|
Column = Data.define(:name, :jade_type, :nullable)
|
|
52
68
|
|
|
53
69
|
def generate(sql, tables: nil, module_name: 'Schema')
|
|
54
|
-
|
|
70
|
+
bodies = scan_table_bodies(sql)
|
|
71
|
+
bodies = select_tables(bodies, tables) if tables
|
|
55
72
|
pks = parse_pks(sql)
|
|
56
|
-
parsed =
|
|
57
|
-
parsed = filter_tables(parsed, tables) if tables
|
|
73
|
+
parsed = bodies.map { |name, body| Table[name, parse_columns(body, name), pks[name] || []] }
|
|
58
74
|
format(emit(parsed, module_name))
|
|
59
75
|
end
|
|
60
76
|
|
|
@@ -77,17 +93,18 @@ module JadeSql
|
|
|
77
93
|
|
|
78
94
|
private
|
|
79
95
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
# Returns [[name, body], ...] without parsing columns, so the whitelist
|
|
97
|
+
# can be applied before type-mapping — an unsupported type in a table the
|
|
98
|
+
# caller didn't ask for shouldn't abort the whole run.
|
|
99
|
+
def scan_table_bodies(sql)
|
|
100
|
+
sql.scan(/CREATE TABLE (?:\w+\.)?(\w+)\s*\((.*?)\);/m)
|
|
85
101
|
end
|
|
86
102
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
def select_tables(bodies, whitelist)
|
|
104
|
+
missing = whitelist - bodies.map(&:first)
|
|
105
|
+
raise "Unknown table(s): #{missing.join(', ')}" if missing.any?
|
|
106
|
+
|
|
107
|
+
bodies.select { |name, _| whitelist.include?(name) }
|
|
91
108
|
end
|
|
92
109
|
|
|
93
110
|
def parse_columns(body, table_name)
|
|
@@ -127,17 +144,30 @@ module JadeSql
|
|
|
127
144
|
def emit(tables, module_name)
|
|
128
145
|
[
|
|
129
146
|
emit_header(tables, module_name),
|
|
130
|
-
*tables.flat_map { |t|
|
|
147
|
+
*tables.flat_map { |t|
|
|
148
|
+
parts = [emit_strict_cols(t), emit_maybe_cols(t), emit_row(t), emit_table_fn(t)]
|
|
149
|
+
reserved_cols?(t) ? parts + [emit_row_projector(t)] : parts
|
|
150
|
+
},
|
|
131
151
|
].join("\n\n") + "\n"
|
|
132
152
|
end
|
|
133
153
|
|
|
154
|
+
def reserved_cols?(t)
|
|
155
|
+
t.columns.any? { |c| RESERVED.include?(c.name) }
|
|
156
|
+
end
|
|
157
|
+
|
|
134
158
|
def emit_header(tables, module_name)
|
|
135
|
-
|
|
159
|
+
projectored = tables.select { |t| reserved_cols?(t) }
|
|
160
|
+
|
|
161
|
+
names = tables
|
|
136
162
|
.flat_map { |t| ["#{camel(t.name)}Cols", "Maybe#{camel(t.name)}Cols", "#{camel(t.name)}Row(..)", t.name] }
|
|
137
|
-
|
|
138
|
-
|
|
163
|
+
names += projectored.map { |t| "#{t.name}_row" }
|
|
164
|
+
exposed = names.sort.join(", ")
|
|
139
165
|
|
|
140
|
-
|
|
166
|
+
sql_import = projectored.any? ?
|
|
167
|
+
"import Sql exposing(Expr, Selector, Table, column, table)" :
|
|
168
|
+
"import Sql exposing(Expr, Table, column, table)"
|
|
169
|
+
query_import = projectored.any? ? ["import Sql.Query exposing(Q, field_as, select)"] : []
|
|
170
|
+
imports = [sql_import, *query_import, *extra_imports_for(tables)]
|
|
141
171
|
|
|
142
172
|
<<~JADE.strip
|
|
143
173
|
module #{module_name} exposing(#{exposed})
|
|
@@ -158,7 +188,7 @@ module JadeSql
|
|
|
158
188
|
|
|
159
189
|
def emit_strict_cols(t)
|
|
160
190
|
fields = t.columns
|
|
161
|
-
.map { |c| " #{c.name}: Expr(#{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type})" }
|
|
191
|
+
.map { |c| " #{field_name(c.name)}: Expr(#{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type})" }
|
|
162
192
|
.join(",\n")
|
|
163
193
|
|
|
164
194
|
"struct #{camel(t.name)}Cols = {\n#{fields}\n}"
|
|
@@ -166,7 +196,7 @@ module JadeSql
|
|
|
166
196
|
|
|
167
197
|
def emit_maybe_cols(t)
|
|
168
198
|
fields = t.columns
|
|
169
|
-
.map { |c| " #{c.name}: Expr(Maybe(#{c.jade_type}))" }
|
|
199
|
+
.map { |c| " #{field_name(c.name)}: Expr(Maybe(#{c.jade_type}))" }
|
|
170
200
|
.join(",\n")
|
|
171
201
|
|
|
172
202
|
"struct Maybe#{camel(t.name)}Cols = {\n#{fields}\n}"
|
|
@@ -174,12 +204,16 @@ module JadeSql
|
|
|
174
204
|
|
|
175
205
|
def emit_row(t)
|
|
176
206
|
fields = t.columns
|
|
177
|
-
.map { |c| " #{c.name}: #{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type}" }
|
|
207
|
+
.map { |c| " #{field_name(c.name)}: #{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type}" }
|
|
178
208
|
.join(",\n")
|
|
179
209
|
|
|
180
210
|
"struct #{camel(t.name)}Row = {\n#{fields}\n}"
|
|
181
211
|
end
|
|
182
212
|
|
|
213
|
+
def field_name(name)
|
|
214
|
+
RESERVED.include?(name) ? "#{name}_" : name
|
|
215
|
+
end
|
|
216
|
+
|
|
183
217
|
def emit_table_fn(t)
|
|
184
218
|
strict_fields = t.columns.map { |c| "column(a, #{c.name.inspect})" }.join(", ")
|
|
185
219
|
maybe_fields = t.columns.map { |c| "column(a, #{c.name.inspect})" }.join(", ")
|
|
@@ -198,6 +232,27 @@ module JadeSql
|
|
|
198
232
|
JADE
|
|
199
233
|
end
|
|
200
234
|
|
|
235
|
+
# A row projector that aliases every column to its (possibly renamed)
|
|
236
|
+
# field name, so a reserved-word column like `type` round-trips through
|
|
237
|
+
# decode: `SELECT alias.type AS type_`. Emitted only for tables that have
|
|
238
|
+
# a renamed column. Composes in a bind-chain:
|
|
239
|
+
# c <- from(t)
|
|
240
|
+
# t_row(c) |> where(...)
|
|
241
|
+
def emit_row_projector(t)
|
|
242
|
+
klass = camel(t.name)
|
|
243
|
+
holes = t.columns.map { "_" }.join(", ")
|
|
244
|
+
projections = t.columns
|
|
245
|
+
.map { |c| " |> field_as(c.#{field_name(c.name)}, #{field_name(c.name).inspect})" }
|
|
246
|
+
.join("\n")
|
|
247
|
+
|
|
248
|
+
<<~JADE.strip
|
|
249
|
+
def #{t.name}_row(c: #{klass}Cols) -> Q(Selector(#{klass}Row))
|
|
250
|
+
select(#{klass}Row(#{holes}))
|
|
251
|
+
#{projections}
|
|
252
|
+
end
|
|
253
|
+
JADE
|
|
254
|
+
end
|
|
255
|
+
|
|
201
256
|
def camel(snake)
|
|
202
257
|
snake.split('_').map(&:capitalize).join
|
|
203
258
|
end
|
data/lib/jade-sql/runtime.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'date'
|
|
2
|
+
require 'bigdecimal'
|
|
2
3
|
require 'jade/tasks'
|
|
3
4
|
|
|
4
5
|
# Opt-in runtime: requires ActiveRecord. The Jade-side Sql.Run module
|
|
@@ -11,7 +12,9 @@ module JadeSql
|
|
|
11
12
|
task :port_execute_count do |t, pair|
|
|
12
13
|
sql, params = pair._1, pair._2
|
|
13
14
|
conn = ::ActiveRecord::Base.connection
|
|
14
|
-
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)))
|
|
15
18
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
16
19
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
17
20
|
end
|
|
@@ -19,12 +22,14 @@ module JadeSql
|
|
|
19
22
|
task :port_execute_one do |t, pair|
|
|
20
23
|
sql, params = pair._1, pair._2
|
|
21
24
|
conn = ::ActiveRecord::Base.connection
|
|
22
|
-
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
|
|
23
26
|
case rows.length
|
|
24
27
|
when 0 then t.err(JadeSql::SqlErrors.not_found)
|
|
25
28
|
when 1 then t.ok(coerce_row(rows.first))
|
|
26
29
|
else t.err(JadeSql::SqlErrors.not_unique)
|
|
27
30
|
end
|
|
31
|
+
rescue ::ActiveRecord::RecordNotUnique => e
|
|
32
|
+
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
28
33
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
29
34
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
30
35
|
end
|
|
@@ -32,8 +37,10 @@ module JadeSql
|
|
|
32
37
|
task :port_execute_many do |t, pair|
|
|
33
38
|
sql, params = pair._1, pair._2
|
|
34
39
|
conn = ::ActiveRecord::Base.connection
|
|
35
|
-
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
|
|
36
41
|
t.ok(rows.map { |row| coerce_row(row) })
|
|
42
|
+
rescue ::ActiveRecord::RecordNotUnique => e
|
|
43
|
+
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
37
44
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
38
45
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
39
46
|
end
|
|
@@ -73,6 +80,10 @@ module JadeSql
|
|
|
73
80
|
# when AR's exec_query path doesn't run the OID typecast. Parse them
|
|
74
81
|
# back to Ruby Arrays so `Decode.list(...)` works the same as for any
|
|
75
82
|
# other List(a) column.
|
|
83
|
+
#
|
|
84
|
+
# numeric/decimal columns come back as ::BigDecimal; the schema generator
|
|
85
|
+
# maps them to Sql.Decimal, whose decoder reads the exact
|
|
86
|
+
# "<mantissa>e<exponent>" wire form. Float would lose precision, so don't.
|
|
76
87
|
def self.coerce_row(row)
|
|
77
88
|
row.transform_values { |v| coerce_value(v) }
|
|
78
89
|
end
|
|
@@ -81,12 +92,28 @@ module JadeSql
|
|
|
81
92
|
case v
|
|
82
93
|
when ::Date then v.iso8601
|
|
83
94
|
when ::Time, ::DateTime then v.iso8601
|
|
95
|
+
when ::BigDecimal then decimal_wire(v)
|
|
84
96
|
when ::String
|
|
85
97
|
pg_array_literal?(v) ? parse_pg_array(v) : v
|
|
86
98
|
else v
|
|
87
99
|
end
|
|
88
100
|
end
|
|
89
101
|
|
|
102
|
+
# ::BigDecimal -> "<mantissa>e<exponent>" with value = mantissa * 10^exp,
|
|
103
|
+
# exactly (BigDecimal#split gives sign, significant digits, and a base-10
|
|
104
|
+
# exponent). Matches the wire form Sql.Decimal's decoder parses.
|
|
105
|
+
#
|
|
106
|
+
# 'NaN'/'Infinity'::numeric are legal Postgres values that Sql.Decimal
|
|
107
|
+
# can't represent; fail loudly rather than silently decode them as 0.
|
|
108
|
+
def self.decimal_wire(v)
|
|
109
|
+
raise ArgumentError, "non-finite numeric: #{v}" unless v.finite?
|
|
110
|
+
|
|
111
|
+
sign, digits, _base, exp = v.split
|
|
112
|
+
mantissa = sign * digits.to_i
|
|
113
|
+
exponent = exp - digits.length
|
|
114
|
+
"#{mantissa}e#{exponent}"
|
|
115
|
+
end
|
|
116
|
+
|
|
90
117
|
# PG arrays render as `{}`, `{a,b,c}`, `{"a,b","c"}`, with NULL as
|
|
91
118
|
# bare `NULL`. Quoted elements escape `"` and `\` with backslashes.
|
|
92
119
|
# The JSON-object guard rejects `{"key":...}` shapes — they share the
|
|
@@ -139,6 +166,32 @@ module JadeSql
|
|
|
139
166
|
raw == "NULL" ? nil : raw
|
|
140
167
|
end
|
|
141
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
|
+
|
|
142
195
|
# Sql renders `?` placeholders uniformly. AR's exec_query/exec_update
|
|
143
196
|
# path on the PG adapter expects `$1, $2, …` — there is no `?`-to-`$n`
|
|
144
197
|
# rewrite at that layer. SQLite and MySQL accept `?` directly, so this
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
|
@@ -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/query.jd
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Sql.Query exposing (
|
|
2
2
|
Q,
|
|
3
3
|
field,
|
|
4
|
+
field_as,
|
|
4
5
|
from,
|
|
5
6
|
group,
|
|
6
7
|
join,
|
|
@@ -235,6 +236,23 @@ def field(qs: Q(Selector(a -> b)), e: Expr(a)) -> Q(Selector(b))
|
|
|
235
236
|
end
|
|
236
237
|
|
|
237
238
|
|
|
239
|
+
def field_as(qs: Q(Selector(a -> b)), e: Expr(a), name: String) -> Q(Selector(b))
|
|
240
|
+
Q(
|
|
241
|
+
qs.tables,
|
|
242
|
+
qs.joins,
|
|
243
|
+
qs.wheres,
|
|
244
|
+
qs.groups,
|
|
245
|
+
qs.orders,
|
|
246
|
+
qs.limit_,
|
|
247
|
+
qs.offset_,
|
|
248
|
+
Selector(
|
|
249
|
+
qs.result.columns_sql ++ [e.sql ++ " AS " ++ name],
|
|
250
|
+
qs.result.params ++ e.params,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
|
|
238
256
|
def join_keyword(k: JoinKind) -> String
|
|
239
257
|
case k
|
|
240
258
|
in InnerJ then "INNER JOIN"
|
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
|
|
@@ -42,6 +42,7 @@ 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
|
|
45
46
|
- lib/jade-sql/sql/loader.jd
|
|
46
47
|
- lib/jade-sql/sql/mutation.jd
|
|
47
48
|
- lib/jade-sql/sql/query.jd
|