jade-sql 0.7.0 → 0.8.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.
@@ -1,4 +1,5 @@
1
1
  require 'jade-sql/bin/generate_schema'
2
+ require 'jade-sql/schema_drift'
2
3
 
3
4
  namespace :jade do
4
5
  desc "Generate schema.jd from db/structure.sql (INPUT, OUTPUT, TABLES, COLUMNS, MODULE)"
@@ -9,15 +10,47 @@ namespace :jade do
9
10
  columns = parse_columns_env(ENV['COLUMNS'])
10
11
  module_name = ENV['MODULE'] || 'Schema'
11
12
 
12
- FileUtils.mkdir_p(File.dirname(output))
13
- File.write(
14
- output,
15
- JadeSql::SchemaGenerator.generate(
16
- File.read(input), tables:, columns:, module_name:
17
- ),
18
- )
13
+ JadeSql::SchemaGenerator
14
+ .generate(File.read(input), tables:, columns:, module_name:)
15
+ .map { |name, source| write_module(output, module_name, name, source) }
16
+ .then { puts "wrote #{it.join(', ')}" }
17
+ end
18
+
19
+ # Every module the generator would write, read back if it is there. A file
20
+ # left behind for a module no longer generated is not found this way, which
21
+ # is the lesser problem: a missing one breaks the build, a stale one is dead
22
+ # code the compiler still reads.
23
+ def on_disk_modules(generated, root_module, output)
24
+ generated.keys.to_h do |name|
25
+ JadeSql::SchemaGenerator
26
+ .module_path(root_module, name, output)
27
+ .then { [name, File.exist?(it) ? File.read(it) : nil] }
28
+ end
29
+ end
30
+
31
+ def write_module(output, root_module, name, source)
32
+ JadeSql::SchemaGenerator
33
+ .module_path(root_module, name, output)
34
+ .tap { FileUtils.mkdir_p(File.dirname(it)) }
35
+ .tap { File.write(it, source) }
36
+ end
37
+
38
+ namespace :schema do
39
+ desc "Fail if schema.jd no longer matches db/structure.sql (INPUT, OUTPUT, TABLES, COLUMNS, MODULE)"
40
+ task :check do
41
+ input = ENV['INPUT'] || 'db/structure.sql'
42
+ output = ENV['OUTPUT'] || 'app/jade/schema.jd'
43
+ tables = ENV['TABLES']&.split(',')&.map(&:strip)&.reject(&:empty?)
44
+ columns = parse_columns_env(ENV['COLUMNS'])
45
+ module_name = ENV['MODULE'] || 'Schema'
46
+
47
+ abort "#{output} does not exist. Generate it with `rake jade:schema`." unless File.exist?(output)
19
48
 
20
- puts "wrote #{output}"
49
+ JadeSql::SchemaGenerator
50
+ .generate(File.read(input), tables:, columns:, module_name:)
51
+ .then { JadeSql::SchemaDrift.between(it, on_disk_modules(it, module_name, output)) }
52
+ .then { it.any? ? abort(it.to_s) : puts("#{output} matches #{input}.") }
53
+ end
21
54
  end
22
55
 
23
56
  # COLUMNS="patients:id,age;visits:id,seen_on" — tables separated by `;`,
@@ -1,3 +1,3 @@
1
1
  module JadeSql
2
- VERSION = '0.7.0'
2
+ VERSION = '0.8.0'
3
3
  end
data/lib/jade-sql.rb CHANGED
@@ -4,6 +4,7 @@ require_relative 'jade-sql/version'
4
4
 
5
5
  Jade.extension(__FILE__)
6
6
 
7
+ require_relative 'jade-sql/compiler'
7
8
  require_relative 'jade-sql/uuid_runtime'
8
9
 
9
10
  # Encoded `Sql.SqlError` values (the `[tag, ...args]` shape produced
@@ -12,12 +13,12 @@ require_relative 'jade-sql/uuid_runtime'
12
13
  module JadeSql
13
14
  module SqlErrors
14
15
  NOT_FOUND = ["NotFound"].freeze
15
- NOT_UNIQUE = ["NotUnique"].freeze
16
+ TOO_MANY_ROWS = ["TooManyRows"].freeze
16
17
 
17
18
  def self.db_error(msg) = ["DbError", msg]
18
19
  def self.not_found = NOT_FOUND
19
- def self.not_unique = NOT_UNIQUE
20
- def self.conflict(name) = ["Conflict", name]
20
+ def self.too_many_rows = TOO_MANY_ROWS
21
+ def self.unique_violation(name) = ["UniqueViolation", name]
21
22
  end
22
23
  end
23
24
 
@@ -26,14 +27,14 @@ module Sql
26
27
  class Error < StandardError; end
27
28
  class DbError < Error; end
28
29
  class NotFound < Error; end
29
- class NotUnique < Error; end
30
- class Conflict < Error; end
30
+ class TooManyRows < Error; end
31
+ class UniqueViolation < Error; end
31
32
 
32
33
  BY_TAG = {
33
34
  "DbError" => DbError,
34
35
  "NotFound" => NotFound,
35
- "NotUnique" => NotUnique,
36
- "Conflict" => Conflict,
36
+ "TooManyRows" => TooManyRows,
37
+ "UniqueViolation" => UniqueViolation,
37
38
  }.freeze
38
39
  end
39
40
 
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - agustin
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-14 00:00:00.000000000 Z
10
+ date: 2026-09-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: jade-lang
@@ -15,17 +15,17 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.6.0
18
+ version: 0.10.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.6.0
26
- description: Query and mutation builders, schema generation from db/structure.sql,
27
- and an ActiveRecord-backed runtime for the Jade language. Renders typed queries
28
- to (String, List(Value)) and decodes rows into Jade structs.
25
+ version: 0.10.0
26
+ description: Query and write builders, schema generation from db/structure.sql, and
27
+ an ActiveRecord-backed runtime for the Jade language. Renders typed queries to (String,
28
+ List(Value)) and decodes rows into Jade structs.
29
29
  email:
30
30
  - agustincornu@fastmail.com
31
31
  executables:
@@ -40,12 +40,20 @@ files:
40
40
  - exe/jade-sql
41
41
  - lib/jade-sql.rb
42
42
  - lib/jade-sql/bin/generate_schema.rb
43
+ - lib/jade-sql/compiler.rb
44
+ - lib/jade-sql/compiler/assignable.rb
45
+ - lib/jade-sql/compiler/columns.rb
46
+ - lib/jade-sql/compiler/errors.rb
47
+ - lib/jade-sql/compiler/selectable.rb
43
48
  - lib/jade-sql/runtime.rb
49
+ - lib/jade-sql/schema_drift.rb
44
50
  - lib/jade-sql/sql.jd
51
+ - lib/jade-sql/sql/expr.jd
52
+ - lib/jade-sql/sql/json.jd
45
53
  - lib/jade-sql/sql/loader.jd
46
- - lib/jade-sql/sql/mutation.jd
47
54
  - lib/jade-sql/sql/query.jd
48
55
  - lib/jade-sql/sql/uuid.jd
56
+ - lib/jade-sql/sql/write.jd
49
57
  - lib/jade-sql/tasks.rake
50
58
  - lib/jade-sql/uuid_runtime.rb
51
59
  - lib/jade-sql/version.rb
@@ -1,464 +0,0 @@
1
- module Sql.Mutation exposing (
2
- Mutation,
3
- delete,
4
- delete_all,
5
- insert,
6
- insert_all,
7
- returning,
8
- timestamped,
9
- to_sql,
10
- update,
11
- update_all,
12
- update_many,
13
- )
14
-
15
- import Sql exposing (
16
- Assignment(..),
17
- Expr(..),
18
- Renderable,
19
- Selector(..),
20
- Table,
21
- TableRef(..),
22
- pk_values,
23
- set_,
24
- to_assigns,
25
- )
26
- import Sql.Query exposing (Q)
27
- import Decode exposing (Value)
28
- import Encode
29
-
30
-
31
- type MutationKind
32
- = InsertK
33
- | UpdateK
34
- | DeleteK
35
-
36
-
37
- struct Mutation(ret, c) = {
38
- kind: MutationKind,
39
- table: TableRef,
40
- cols: c,
41
- rows: List(List(Assignment)),
42
- wheres: List(Expr(Bool)),
43
- returning_selector: Selector(ret),
44
- from_: Maybe(Expr(Bool))
45
- }
46
-
47
-
48
- def insert(value: a, t: Table(c, m)) -> Mutation(Int, c)
49
- Mutation(
50
- InsertK,
51
- TableRef(t.name, t.alias_),
52
- t.alias_ |> t.cols,
53
- [to_assigns(value)],
54
- [],
55
- Selector([], []),
56
- Nothing,
57
- )
58
- end
59
-
60
-
61
- def insert_all(values: List(a), t: Table(c, m)) -> Mutation(Int, c)
62
- Mutation(
63
- InsertK,
64
- TableRef(t.name, t.alias_),
65
- t.alias_ |> t.cols,
66
- List.map(values, (v) -> { to_assigns(v) }),
67
- [],
68
- Selector([], []),
69
- Nothing,
70
- )
71
- end
72
-
73
-
74
- def update(value: a, t: Table(c, m)) -> Mutation(Int, c)
75
- pk_pred = pk_predicate(t.pk_columns, pk_values(value))
76
-
77
- Mutation(
78
- UpdateK,
79
- TableRef(t.name, t.alias_),
80
- t.alias_ |> t.cols,
81
- [to_assigns(value)],
82
- [pk_pred],
83
- Selector([], []),
84
- Nothing,
85
- )
86
- end
87
-
88
-
89
- def delete(value: a, t: Table(c, m)) -> Mutation(Int, c)
90
- pk_pred = pk_predicate(t.pk_columns, pk_values(value))
91
-
92
- Mutation(
93
- DeleteK,
94
- TableRef(t.name, t.alias_),
95
- t.alias_ |> t.cols,
96
- [],
97
- [pk_pred],
98
- Selector([], []),
99
- Nothing,
100
- )
101
- end
102
-
103
-
104
- def update_all(
105
- t: Table(c, m),
106
- pred_fn: c -> Expr(Bool),
107
- build: c -> List(Assignment),
108
- ) -> Mutation(Int, c)
109
- cols_ = t.alias_ |> t.cols
110
- pred = cols_ |> pred_fn
111
-
112
- Mutation(
113
- UpdateK,
114
- TableRef(t.name, t.alias_),
115
- cols_,
116
- [cols_ |> build],
117
- [pred],
118
- Selector([], []),
119
- Nothing,
120
- )
121
- end
122
-
123
-
124
- def update_many(values: List(a), t: Table(c, m)) -> Mutation(Int, c)
125
- rows = List.zip(
126
- values |> List.map((v) -> { pk_values(v) }),
127
- values |> List.map((v) -> { to_assigns(v) }),
128
- )
129
- |> List.map((p) -> { RowUpdate(Tuple.first(p), Tuple.second(p)) })
130
-
131
- Mutation(
132
- UpdateK,
133
- TableRef(t.name, t.alias_),
134
- t.alias_ |> t.cols,
135
- [source_assigns(rows)],
136
- [pk_join(t.pk_columns)],
137
- Selector([], []),
138
- Just(source_of(t.name, t.pk_columns, rows)),
139
- )
140
- end
141
-
142
-
143
- struct RowUpdate = {
144
- keys: List(Value),
145
- assigns: List(Assignment)
146
- }
147
-
148
-
149
- # One JSON parameter carries every row, so the statement is the same size
150
- # for two rows or two hundred. json_populate_recordset gives the record
151
- # the table's own column types, which is what makes a bare `?` legal in a
152
- # position Postgres cannot otherwise infer.
153
- def source_of(
154
- name: String,
155
- pk_columns: List(String),
156
- rows: List(RowUpdate),
157
- ) -> Expr(Bool)
158
- Expr(
159
- "json_populate_recordset(null::" ++ name ++ ", ?::json) AS jade_src",
160
- [Encode.string(Encode.encode_to_string(json_rows(pk_columns, rows)))],
161
- )
162
- end
163
-
164
-
165
- def json_rows(pk_columns: List(String), rows: List(RowUpdate)) -> Value
166
- Encode.list(json_row(pk_columns, _), rows)
167
- end
168
-
169
-
170
- def json_row(pk_columns: List(String), r: RowUpdate) -> Value
171
- Encode.object(
172
- List.zip(pk_columns, r.keys)
173
- ++ (r.assigns |> List.map((a) -> { Tuple.Tuple2(a.col, first_param(a)) })),
174
- )
175
- end
176
-
177
-
178
- def first_param(a: Assignment) -> Value
179
- a.params |> List.head |> Maybe.with_default(Encode.null)
180
- end
181
-
182
-
183
- def source_assigns(rows: List(RowUpdate)) -> List(Assignment)
184
- rows
185
- |> touched_cols
186
- |> List.map((col) -> { Assignment(col, "jade_src." ++ col, []) })
187
- end
188
-
189
-
190
- def touched_cols(rows: List(RowUpdate)) -> List(String)
191
- rows
192
- |> List.and_then(.assigns)
193
- |> List.map(.col)
194
- |> List.fold([], (seen, c) -> { List.member?(seen, c) ? seen : seen ++ [c] })
195
- end
196
-
197
-
198
- def pk_join(pk_columns: List(String)) -> Expr(Bool)
199
- Expr(
200
- pk_columns
201
- |> List.map((col) -> { "jade_tgt." ++ col ++ " = jade_src." ++ col })
202
- |> String.join(" AND "),
203
- [],
204
- )
205
- end
206
-
207
-
208
- def delete_all(t: Table(c, m), pred_fn: c -> Expr(Bool)) -> Mutation(Int, c)
209
- cols_ = t.alias_ |> t.cols
210
- pred = cols_ |> pred_fn
211
-
212
- Mutation(
213
- DeleteK,
214
- TableRef(t.name, t.alias_),
215
- cols_,
216
- [],
217
- [pred],
218
- Selector([], []),
219
- Nothing,
220
- )
221
- end
222
-
223
-
224
- def returning(m: Mutation(a, c), build: c -> Q(Selector(b))) -> Mutation(b, c)
225
- Mutation(
226
- m.kind,
227
- m.table,
228
- m.cols,
229
- m.rows,
230
- m.wheres,
231
- build(m.cols).result,
232
- m.from_,
233
- )
234
- end
235
-
236
-
237
- # Opt-in ActiveRecord-style timestamps: fills created_at + updated_at on
238
- # insert and updated_at on update, with the app clock at execute time.
239
- # The "$JADE_SQL_NOW$" token carries no param; the runtime swaps it for a
240
- # single UTC timestamp literal per statement (so created_at == updated_at).
241
- # A plain `insert`/`update` without this stays timestamp-free.
242
- def timestamped(m: Mutation(ret, c)) -> Mutation(ret, c)
243
- case m.kind
244
- in InsertK then with_rows(m, List.map(m.rows, append_insert_stamps))
245
- in UpdateK then with_rows(m, List.map(m.rows, append_update_stamps))
246
- in DeleteK then m
247
- end
248
- end
249
-
250
-
251
- def append_insert_stamps(row: List(Assignment)) -> List(Assignment)
252
- row ++ [now_assignment("created_at"), now_assignment("updated_at")]
253
- end
254
-
255
-
256
- def append_update_stamps(row: List(Assignment)) -> List(Assignment)
257
- row ++ [now_assignment("updated_at")]
258
- end
259
-
260
-
261
- def now_assignment(col: String) -> Assignment
262
- Assignment(col, "$JADE_SQL_NOW$", [])
263
- end
264
-
265
-
266
- def with_rows(m: Mutation(ret, c), rows: List(List(Assignment))) -> Mutation(ret, c)
267
- { m | rows: rows }
268
- end
269
-
270
-
271
- def pk_predicate(cols: List(String), values: List(Value)) -> Expr(Bool)
272
- sql = cols
273
- |> List.map((col) -> { col ++ " = ?" })
274
- |> String.join(" AND ")
275
-
276
- Expr(sql, values)
277
- end
278
-
279
-
280
- def render_row(row: List(Assignment)) -> String
281
- cols_part = row
282
- |> List.map((a) -> { a.value_sql })
283
- |> String.join(", ")
284
-
285
- "(" ++ cols_part ++ ")"
286
- end
287
-
288
-
289
- def returning_clause(s: Selector(ret)) -> String
290
- List.empty?(s.columns_sql) ? "" : " RETURNING " ++ String.join(s.columns_sql, ", ")
291
- end
292
-
293
-
294
- def insert_cols_str(rows: List(List(Assignment))) -> String
295
- case rows
296
- in [] then ""
297
- in [first | _]
298
- "(" ++ String.join(List.map(first, (a) -> { a.col }), ", ") ++ ")"
299
- end
300
- end
301
-
302
-
303
- def insert_params(rows: List(List(Assignment))) -> List(Value)
304
- List.fold(
305
- rows,
306
- [],
307
- (acc, row) -> { acc ++ List.fold(row, [], (acc2, a) -> { acc2 ++ a.params }) },
308
- )
309
- end
310
-
311
-
312
- def set_sqls_for(rows: List(List(Assignment))) -> List(String)
313
- case rows
314
- in [first | _] then List.map(first, (a) -> { a.col ++ " = " ++ a.value_sql })
315
- in [] then []
316
- end
317
- end
318
-
319
-
320
- def set_params_for(rows: List(List(Assignment))) -> List(Value)
321
- case rows
322
- in [first | _] then List.fold(first, [], (acc, a) -> { acc ++ a.params })
323
- in [] then []
324
- end
325
- end
326
-
327
-
328
- # A FROM'd UPDATE needs the target aliased: json_populate_recordset gives
329
- # the source every column the table has, so a bare `id = jade_src.id` is
330
- # ambiguous.
331
- def target_ref(m: Mutation(ret, c)) -> String
332
- case m.from_
333
- in Nothing then m.table.name
334
- in Just(_) then m.table.name ++ " AS jade_tgt"
335
- end
336
- end
337
-
338
-
339
- def from_clause(from_: Maybe(Expr(Bool))) -> String
340
- case from_
341
- in Nothing then ""
342
- in Just(e) then " FROM " ++ e.sql
343
- end
344
- end
345
-
346
-
347
- def from_params(from_: Maybe(Expr(Bool))) -> List(Value)
348
- case from_
349
- in Nothing then []
350
- in Just(e) then e.params
351
- end
352
- end
353
-
354
-
355
- def where_sql(wheres: List(Expr(Bool))) -> String
356
- List.empty?(wheres)
357
- ? ""
358
- : " WHERE " ++ String.join(List.map(wheres, (w) -> { w.sql }), " AND ")
359
- end
360
-
361
-
362
- def where_params(wheres: List(Expr(Bool))) -> List(Value)
363
- List.fold(wheres, [], (acc, w) -> { acc ++ w.params })
364
- end
365
-
366
-
367
- def strip_table_alias(sql: String, alias_: String) -> String
368
- String.replace(sql, alias_ ++ ".", "")
369
- end
370
-
371
-
372
- def render_insert(m: Mutation(ret, c)) -> (String, List(Value))
373
- cols_str = insert_cols_str(m.rows)
374
- values_str = m.rows
375
- |> List.map(render_row)
376
- |> String.join(", ")
377
- returning_ = strip_table_alias(returning_clause(m.returning_selector), m.table.alias_)
378
- sql = "INSERT INTO "
379
- ++ m.table.name
380
- ++ " "
381
- ++ cols_str
382
- ++ " VALUES "
383
- ++ values_str
384
- ++ returning_
385
-
386
- Tuple.Tuple2(sql, insert_params(m.rows) ++ m.returning_selector.params)
387
- end
388
-
389
-
390
- def render_update(m: Mutation(ret, c)) -> (String, List(Value))
391
- sets = set_sqls_for(m.rows)
392
-
393
- List.empty?(sets) ? render_no_op_update(m) : render_update_set(m, sets)
394
- end
395
-
396
-
397
- def render_no_op_update(m: Mutation(ret, c)) -> (String, List(Value))
398
- where = strip_table_alias(where_sql(m.wheres), m.table.alias_)
399
- cols = List.empty?(m.returning_selector.columns_sql)
400
- ? "1"
401
- : strip_table_alias(
402
- String.join(m.returning_selector.columns_sql, ", "),
403
- m.table.alias_,
404
- )
405
- sql = "SELECT " ++ cols ++ " FROM " ++ m.table.name ++ where
406
-
407
- Tuple.Tuple2(sql, m.returning_selector.params ++ where_params(m.wheres))
408
- end
409
-
410
-
411
- def render_update_set(
412
- m: Mutation(ret, c),
413
- sets: List(String),
414
- ) -> (String, List(Value))
415
- set_clause = strip_table_alias(
416
- "SET " ++ String.join(sets, ", "),
417
- m.table.alias_,
418
- )
419
- where = strip_table_alias(where_sql(m.wheres), m.table.alias_)
420
- returning_ = strip_table_alias(returning_clause(m.returning_selector), m.table.alias_)
421
- sql = "UPDATE "
422
- ++ target_ref(m)
423
- ++ " "
424
- ++ set_clause
425
- ++ from_clause(m.from_)
426
- ++ where
427
- ++ returning_
428
-
429
- Tuple.Tuple2(
430
- sql,
431
- set_params_for(m.rows)
432
- ++ from_params(m.from_)
433
- ++ where_params(m.wheres)
434
- ++ m.returning_selector.params,
435
- )
436
- end
437
-
438
-
439
-
440
-
441
- def render_delete(m: Mutation(ret, c)) -> (String, List(Value))
442
- where = strip_table_alias(where_sql(m.wheres), m.table.alias_)
443
- returning_ = strip_table_alias(returning_clause(m.returning_selector), m.table.alias_)
444
- sql = "DELETE FROM "
445
- ++ m.table.name
446
- ++ where
447
- ++ returning_
448
-
449
- Tuple.Tuple2(sql, where_params(m.wheres) ++ m.returning_selector.params)
450
- end
451
-
452
-
453
- def to_sql(m: Mutation(ret, c)) -> (String, List(Value))
454
- case m.kind
455
- in InsertK then render_insert(m)
456
- in UpdateK then render_update(m)
457
- in DeleteK then render_delete(m)
458
- end
459
- end
460
-
461
-
462
- implements Renderable(Mutation(ret, c)) with
463
- render: to_sql
464
- end