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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5b511ba76f20947fbccd7ebb7800746ca4ff7deaa7db27f02c0f9e0c2063fa5
4
- data.tar.gz: 4f280996410b7a415c14ce431879074d3ea14db4da9305358595738a8f31f674
3
+ metadata.gz: 06d76c59dbdec2e461a437690981df433898e1366503d665d2b10778fa504d48
4
+ data.tar.gz: 2c608f51a766507d5f9f13df7acce793177d6241e742edf05968eacff8482525
5
5
  SHA512:
6
- metadata.gz: df9d4acfa3ac509e4a9730a727befae3197cdd9abb9959c60d107191fb574898e1d75ca23eb7814ab5a6b80a81b0e7e5e2d2131954a47053726c0a1e3354e720
7
- data.tar.gz: 582f7bcacd1543c00d6efc7710762fe1b6b9a388c8e71c2fedaf006229d3ac7bce49510473e8ae3b97773314c6ead9445b5f38c8d6aeb678cfbc6c573adae763
6
+ metadata.gz: f87fac54b24c1a530fa20ee750168654ec6c7a20d60dc5f09afb556862a132ae71e3a8c5e1683e041ccd749ca6fd51db3615a04ac6bfc631c03e61fc533fba68
7
+ data.tar.gz: 8e6fe0954616823a3cf8b44da971562a4cf8fbefa50cad20d66604e77c8c016a6b0c12a5c8e3bbfb5e5c72afe10961db4a4b996bf1b08d22ae4a4b4682068faf
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # jade-sql
2
2
 
3
+ [![CI](https://github.com/agustinrhcp/jade-sql/actions/workflows/ci.yml/badge.svg)](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`, `varchar`/`text`/`char` →
31
- `String`, `boolean` `Bool`, `jsonb`/`json` → `Decode.Value`, `date` →
32
- `Calendar.Date`, `timestamp` → `Clock.Instant`, `uuid` → `Uuid` (from
33
- `Sql.Uuid`). Unknown types fail loudly with the table+column name.
34
- `bytea` and `decimal`/`numeric` aren't wired into the type map yet
35
- `bytea` has a natural target in jade's `Bytes`, and a decimal can be
36
- modeled as a struct of `Int`s.
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
- `Mutation.insert`/`update` emit only the columns you explicitly set —
419
- they don't auto-fill `created_at` / `updated_at`. Two ways to handle
420
- NOT NULL timestamp columns:
421
-
422
- **1. DB-side defaults (recommended).** Let the schema own timestamp
423
- policy:
424
-
425
- ```sql
426
- ALTER TABLE patients ALTER COLUMN created_at SET DEFAULT now();
427
- ALTER TABLE patients ALTER COLUMN updated_at SET DEFAULT now();
428
- ```
429
-
430
- The mutation builder stays a thin SQL emitter; the DB fills in
431
- defaults for any column the INSERT didn't list.
432
-
433
- **2. Explicit injection in app code.** When you want Jade to carry the
434
- timestamp values (Rails-style), tack assignments onto the list before
435
- the call:
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
- def with_timestamps(assigns: List(Assignment), now: Instant)
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
- def create(p: Patient, now: Instant) -> Mutation(Int, PatientsCols)
444
- encode_patient(p)
445
- |> with_timestamps(now)
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
- parsed = parse_tables(sql)
70
+ bodies = scan_table_bodies(sql)
71
+ bodies = select_tables(bodies, tables) if tables
55
72
  pks = parse_pks(sql)
56
- parsed = parsed.map { |t| t.with(pk_columns: pks[t.name] || []) }
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
- def filter_tables(parsed, whitelist)
81
- missing = whitelist - parsed.map(&:name)
82
- raise "Unknown table(s): #{missing.join(', ')}" if missing.any?
83
-
84
- parsed.select { |t| whitelist.include?(t.name) }
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 parse_tables(sql)
88
- sql
89
- .scan(/CREATE TABLE (?:\w+\.)?(\w+)\s*\((.*?)\);/m)
90
- .map { |name, body| Table[name, parse_columns(body, name), []] }
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| [emit_strict_cols(t), emit_maybe_cols(t), emit_row(t), emit_table_fn(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
- exposed = tables
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
- .sort
138
- .join(", ")
163
+ names += projectored.map { |t| "#{t.name}_row" }
164
+ exposed = names.sort.join(", ")
139
165
 
140
- imports = ["import Sql exposing(Expr, Table, column, table)", *extra_imports_for(tables)]
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
@@ -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 ++ " = ?" })
@@ -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),
@@ -1,3 +1,3 @@
1
1
  module JadeSql
2
- VERSION = '0.1.0'
2
+ VERSION = '0.3.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.1.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-14 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
@@ -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