jade-sql 0.6.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.6.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.6.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-03 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.4.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.4.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,329 +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
- )
13
-
14
- import Sql exposing (
15
- Assignment(..),
16
- Expr(..),
17
- Renderable,
18
- Selector(..),
19
- Table,
20
- TableRef(..),
21
- pk_values,
22
- set_,
23
- to_assigns,
24
- )
25
- import Sql.Query exposing (Q)
26
- import Decode exposing (Value)
27
-
28
-
29
- type MutationKind
30
- = InsertK
31
- | UpdateK
32
- | DeleteK
33
-
34
-
35
- struct Mutation(ret, c) = {
36
- kind: MutationKind,
37
- table: TableRef,
38
- cols: c,
39
- rows: List(List(Assignment)),
40
- wheres: List(Expr(Bool)),
41
- returning_selector: Selector(ret)
42
- }
43
-
44
-
45
- def insert(value: a, t: Table(c, m)) -> Mutation(Int, c)
46
- Mutation(
47
- InsertK,
48
- TableRef(t.name, t.alias_),
49
- t.alias_ |> t.cols,
50
- [to_assigns(value)],
51
- [],
52
- Selector([], []),
53
- )
54
- end
55
-
56
-
57
- def insert_all(values: List(a), t: Table(c, m)) -> Mutation(Int, c)
58
- Mutation(
59
- InsertK,
60
- TableRef(t.name, t.alias_),
61
- t.alias_ |> t.cols,
62
- List.map(values, (v) -> { to_assigns(v) }),
63
- [],
64
- Selector([], []),
65
- )
66
- end
67
-
68
-
69
- def update(value: a, t: Table(c, m)) -> Mutation(Int, c)
70
- pk_pred = pk_predicate(t.pk_columns, pk_values(value))
71
-
72
- Mutation(
73
- UpdateK,
74
- TableRef(t.name, t.alias_),
75
- t.alias_ |> t.cols,
76
- [to_assigns(value)],
77
- [pk_pred],
78
- Selector([], []),
79
- )
80
- end
81
-
82
-
83
- def delete(value: a, t: Table(c, m)) -> Mutation(Int, c)
84
- pk_pred = pk_predicate(t.pk_columns, pk_values(value))
85
-
86
- Mutation(
87
- DeleteK,
88
- TableRef(t.name, t.alias_),
89
- t.alias_ |> t.cols,
90
- [],
91
- [pk_pred],
92
- Selector([], []),
93
- )
94
- end
95
-
96
-
97
- def update_all(
98
- t: Table(c, m),
99
- pred_fn: c -> Expr(Bool),
100
- build: c -> List(Assignment),
101
- ) -> Mutation(Int, c)
102
- cols_ = t.alias_ |> t.cols
103
- pred = cols_ |> pred_fn
104
-
105
- Mutation(
106
- UpdateK,
107
- TableRef(t.name, t.alias_),
108
- cols_,
109
- [cols_ |> build],
110
- [pred],
111
- Selector([], []),
112
- )
113
- end
114
-
115
-
116
- def delete_all(t: Table(c, m), pred_fn: c -> Expr(Bool)) -> Mutation(Int, c)
117
- cols_ = t.alias_ |> t.cols
118
- pred = cols_ |> pred_fn
119
-
120
- Mutation(
121
- DeleteK,
122
- TableRef(t.name, t.alias_),
123
- cols_,
124
- [],
125
- [pred],
126
- Selector([], []),
127
- )
128
- end
129
-
130
-
131
- def returning(m: Mutation(a, c), build: c -> Q(Selector(b))) -> Mutation(b, c)
132
- Mutation(m.kind, m.table, m.cols, m.rows, m.wheres, build(m.cols).result)
133
- end
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
-
170
- def pk_predicate(cols: List(String), values: List(Value)) -> Expr(Bool)
171
- sql = cols
172
- |> List.map((col) -> { col ++ " = ?" })
173
- |> String.join(" AND ")
174
-
175
- Expr(sql, values)
176
- end
177
-
178
-
179
- def render_row(row: List(Assignment)) -> String
180
- cols_part = row
181
- |> List.map((a) -> { a.value_sql })
182
- |> String.join(", ")
183
-
184
- "(" ++ cols_part ++ ")"
185
- end
186
-
187
-
188
- def returning_clause(s: Selector(ret)) -> String
189
- List.empty?(s.columns_sql) ? "" : " RETURNING " ++ String.join(s.columns_sql, ", ")
190
- end
191
-
192
-
193
- def insert_cols_str(rows: List(List(Assignment))) -> String
194
- case rows
195
- in [] then ""
196
- in [first | _]
197
- "(" ++ String.join(List.map(first, (a) -> { a.col }), ", ") ++ ")"
198
- end
199
- end
200
-
201
-
202
- def insert_params(rows: List(List(Assignment))) -> List(Value)
203
- List.fold(
204
- rows,
205
- [],
206
- (acc, row) -> { acc ++ List.fold(row, [], (acc2, a) -> { acc2 ++ a.params }) },
207
- )
208
- end
209
-
210
-
211
- def set_sqls_for(rows: List(List(Assignment))) -> List(String)
212
- case rows
213
- in [first | _] then List.map(first, (a) -> { a.col ++ " = " ++ a.value_sql })
214
- in [] then []
215
- end
216
- end
217
-
218
-
219
- def set_params_for(rows: List(List(Assignment))) -> List(Value)
220
- case rows
221
- in [first | _] then List.fold(first, [], (acc, a) -> { acc ++ a.params })
222
- in [] then []
223
- end
224
- end
225
-
226
-
227
- def where_sql(wheres: List(Expr(Bool))) -> String
228
- List.empty?(wheres)
229
- ? ""
230
- : " WHERE " ++ String.join(List.map(wheres, (w) -> { w.sql }), " AND ")
231
- end
232
-
233
-
234
- def where_params(wheres: List(Expr(Bool))) -> List(Value)
235
- List.fold(wheres, [], (acc, w) -> { acc ++ w.params })
236
- end
237
-
238
-
239
- def strip_table_alias(sql: String, alias_: String) -> String
240
- String.replace(sql, alias_ ++ ".", "")
241
- end
242
-
243
-
244
- def render_insert(m: Mutation(ret, c)) -> (String, List(Value))
245
- cols_str = insert_cols_str(m.rows)
246
- values_str = m.rows
247
- |> List.map(render_row)
248
- |> String.join(", ")
249
- returning_ = strip_table_alias(returning_clause(m.returning_selector), m.table.alias_)
250
- sql = "INSERT INTO "
251
- ++ m.table.name
252
- ++ " "
253
- ++ cols_str
254
- ++ " VALUES "
255
- ++ values_str
256
- ++ returning_
257
-
258
- Tuple.Tuple2(sql, insert_params(m.rows) ++ m.returning_selector.params)
259
- end
260
-
261
-
262
- def render_update(m: Mutation(ret, c)) -> (String, List(Value))
263
- sets = set_sqls_for(m.rows)
264
-
265
- List.empty?(sets) ? render_no_op_update(m) : render_update_set(m, sets)
266
- end
267
-
268
-
269
- def render_no_op_update(m: Mutation(ret, c)) -> (String, List(Value))
270
- where = strip_table_alias(where_sql(m.wheres), m.table.alias_)
271
- cols = List.empty?(m.returning_selector.columns_sql)
272
- ? "1"
273
- : strip_table_alias(
274
- String.join(m.returning_selector.columns_sql, ", "),
275
- m.table.alias_,
276
- )
277
- sql = "SELECT " ++ cols ++ " FROM " ++ m.table.name ++ where
278
-
279
- Tuple.Tuple2(sql, m.returning_selector.params ++ where_params(m.wheres))
280
- end
281
-
282
-
283
- def render_update_set(
284
- m: Mutation(ret, c),
285
- sets: List(String),
286
- ) -> (String, List(Value))
287
- set_clause = "SET " ++ String.join(sets, ", ")
288
- where = strip_table_alias(where_sql(m.wheres), m.table.alias_)
289
- returning_ = strip_table_alias(returning_clause(m.returning_selector), m.table.alias_)
290
- sql = "UPDATE "
291
- ++ m.table.name
292
- ++ " "
293
- ++ set_clause
294
- ++ where
295
- ++ returning_
296
-
297
- Tuple.Tuple2(
298
- sql,
299
- set_params_for(m.rows) ++ where_params(m.wheres) ++ m.returning_selector.params,
300
- )
301
- end
302
-
303
-
304
-
305
-
306
- def render_delete(m: Mutation(ret, c)) -> (String, List(Value))
307
- where = strip_table_alias(where_sql(m.wheres), m.table.alias_)
308
- returning_ = strip_table_alias(returning_clause(m.returning_selector), m.table.alias_)
309
- sql = "DELETE FROM "
310
- ++ m.table.name
311
- ++ where
312
- ++ returning_
313
-
314
- Tuple.Tuple2(sql, where_params(m.wheres) ++ m.returning_selector.params)
315
- end
316
-
317
-
318
- def to_sql(m: Mutation(ret, c)) -> (String, List(Value))
319
- case m.kind
320
- in InsertK then render_insert(m)
321
- in UpdateK then render_update(m)
322
- in DeleteK then render_delete(m)
323
- end
324
- end
325
-
326
-
327
- implements Renderable(Mutation(ret, c)) with
328
- render: to_sql
329
- end