jade-sql 0.2.0 → 0.3.0

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