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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5986c28a9014fcf2a656282a1c34d0303a2c6e37d10dbb5b1eb8591c5a1ebaf
4
- data.tar.gz: 378c771dc40c70f3567727e4475b8383f28a723be48beff82dc5f5c25ea6e8f5
3
+ metadata.gz: 203373e2a0789908e800cda5e81acee5883edb436205ec016f281549451218cd
4
+ data.tar.gz: 3338e7d2e8132fdf4ed4de0e8b53658d8a2cce64ee8f13394cc7dca53413906c
5
5
  SHA512:
6
- metadata.gz: c09395f48662acf1c6ae08189f700229d03a23dbcfe170725f681094cb76357665dc18775c441eb7961f6afcdc841bf035e4fbe1da4d43e8bfe8d3e23f997af1
7
- data.tar.gz: 52fe13eb1ed8ec5c43db442d85923e972b35c26e2be57939e75b8d0f0bb16817ab45404abd3db9ff640b7609e0d4491e199278691c9e07253be97f1bd298a5ed
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` (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).
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
- `Mutation.insert`/`update` emit only the columns you explicitly set —
447
- they don't auto-fill `created_at` / `updated_at`. Two ways to handle
448
- NOT NULL timestamp columns:
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
- def with_timestamps(assigns: List(Assignment), now: Instant)
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
- def create(p: Patient, now: Instant) -> Mutation(Int, PatientsCols)
472
- encode_patient(p)
473
- |> with_timestamps(now)
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 Sql.Decimal exposing(Decimal)",
56
+ "Decimal" => "import Decimal exposing(Decimal)",
57
57
  }.freeze
58
58
 
59
59
  # Column names that collide with Jade keywords get a trailing underscore
@@ -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 Sql.Decimal, whose decoder reads the exact
80
- # "<mantissa>e<exponent>" wire form. Float would lose precision, so don't.
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 -> "<mantissa>e<exponent>" with value = mantissa * 10^exp,
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 Sql.Decimal's decoder parses.
104
+ # exponent). Matches the wire form jade's stdlib Decimal decoder parses.
99
105
  #
100
- # 'NaN'/'Infinity'::numeric are legal Postgres values that Sql.Decimal
101
- # can't represent; fail loudly rather than silently decode them as 0.
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
- mantissa = sign * digits.to_i
112
+ coefficient = sign * digits.to_i
107
113
  exponent = exp - digits.length
108
- "#{mantissa}e#{exponent}"
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),
@@ -1,3 +1,3 @@
1
1
  module JadeSql
2
- VERSION = '0.2.0'
2
+ VERSION = '0.4.0'
3
3
  end
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) = ["DbError", msg]
18
- def self.not_found = NOT_FOUND
19
- def self.not_unique = 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.2.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-15 00:00:00.000000000 Z
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.1.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.1.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
@@ -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