jade-sql 0.1.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.
@@ -0,0 +1,421 @@
1
+ module Sql exposing (
2
+ Assignment(..),
3
+ Expr(..),
4
+ Identified,
5
+ Renderable,
6
+ Selector(..),
7
+ SqlError(..),
8
+ SqlMapper,
9
+ Table,
10
+ TableRef(..),
11
+ aliased,
12
+ and,
13
+ array_append,
14
+ array_concat,
15
+ array_contained_by,
16
+ array_contains,
17
+ array_has,
18
+ array_length,
19
+ array_overlaps,
20
+ array_remove,
21
+ assign,
22
+ cast,
23
+ coalesce,
24
+ column,
25
+ columns,
26
+ count,
27
+ count_all,
28
+ eq,
29
+ execute,
30
+ execute_raw,
31
+ fetch_many,
32
+ fetch_many_raw,
33
+ fetch_one,
34
+ fetch_one_raw,
35
+ in_,
36
+ is_not_null,
37
+ is_null,
38
+ jsonb_contains,
39
+ jsonb_path_exists,
40
+ maybe_columns,
41
+ neg,
42
+ nullable,
43
+ pk_values,
44
+ render,
45
+ set_,
46
+ sum,
47
+ table,
48
+ to_assigns,
49
+ to_expr,
50
+ transaction,
51
+ )
52
+
53
+ import Encode exposing (Encodable, encode)
54
+ import Decode exposing (Decodable, Decoder, Value)
55
+
56
+
57
+ struct Expr(a) = {
58
+ sql: String,
59
+ params: List(Value)
60
+ }
61
+
62
+
63
+ struct Selector(a) = {
64
+ columns_sql: List(String),
65
+ params: List(Value)
66
+ }
67
+
68
+
69
+ struct Table(c, m) = {
70
+ name: String,
71
+ alias_: String,
72
+ cols: String -> c,
73
+ maybe_cols: String -> m,
74
+ pk_columns: List(String)
75
+ }
76
+
77
+
78
+ struct TableRef = {
79
+ name: String,
80
+ alias_: String
81
+ }
82
+
83
+
84
+ struct Assignment = {
85
+ col: String,
86
+ value_sql: String,
87
+ params: List(Value)
88
+ }
89
+
90
+
91
+ def set_(col: Expr(a), value: Expr(a)) -> Assignment
92
+ Assignment(strip_alias(col.sql), value.sql, value.params)
93
+ end
94
+
95
+
96
+ def assign(col: String, value: a) -> Assignment
97
+ Assignment(col, "?", [encode(value)])
98
+ end
99
+
100
+
101
+ def strip_alias(sql: String) -> String
102
+ parts = String.split(sql, ".")
103
+
104
+ case parts
105
+ in [] then sql
106
+ in [name] then name
107
+ in [_ | rest] then String.join(rest, ".")
108
+ end
109
+ end
110
+
111
+
112
+ interface SqlMapper(a) with
113
+ to_assigns : a -> List(Assignment)
114
+ end
115
+
116
+
117
+ implements SqlMapper(List(Assignment)) with
118
+ to_assigns: (a) -> { a }
119
+ end
120
+
121
+
122
+ interface Identified(a) with
123
+ pk_values : a -> List(Value)
124
+ end
125
+
126
+
127
+ interface Renderable(r) with
128
+ render : r -> (String, List(Value))
129
+ end
130
+
131
+
132
+ def column(alias_: String, name: String) -> Expr(a)
133
+ Expr(alias_ ++ "." ++ name, [])
134
+ end
135
+
136
+
137
+ def to_expr(value: a) -> Expr(a)
138
+ Expr("?", [encode(value)])
139
+ end
140
+
141
+
142
+ def eq(left: Expr(a), right: Expr(a)) -> Expr(Bool)
143
+ Expr(left.sql ++ " = " ++ right.sql, left.params ++ right.params)
144
+ end
145
+
146
+
147
+ def is_null(e: Expr(a)) -> Expr(Bool)
148
+ Expr(e.sql ++ " IS NULL", e.params)
149
+ end
150
+
151
+
152
+ def is_not_null(e: Expr(a)) -> Expr(Bool)
153
+ Expr(e.sql ++ " IS NOT NULL", e.params)
154
+ end
155
+
156
+
157
+ def nullable(e: Expr(a)) -> Expr(Maybe(a))
158
+ Expr(e.sql, e.params)
159
+ end
160
+
161
+
162
+ def cast(e: Expr(a)) -> Expr(b)
163
+ Expr(e.sql, e.params)
164
+ end
165
+
166
+
167
+ def and(left: Expr(Bool), right: Expr(Bool)) -> Expr(Bool)
168
+ Expr(left.sql ++ " AND " ++ right.sql, left.params ++ right.params)
169
+ end
170
+
171
+
172
+ # SUM may return NULL on an empty group, so the result is Expr(Maybe(Int)).
173
+ # Wrap with `coalesce(sum(x), to_expr(0))` for a strict total.
174
+ def sum(e: Expr(Int)) -> Expr(Maybe(Int))
175
+ Expr("SUM(" ++ e.sql ++ ")", e.params)
176
+ end
177
+
178
+
179
+ def count(e: Expr(a)) -> Expr(Int)
180
+ Expr("COUNT(" ++ e.sql ++ ")", e.params)
181
+ end
182
+
183
+
184
+ def count_all -> Expr(Int)
185
+ Expr("COUNT(*)", [])
186
+ end
187
+
188
+
189
+ def coalesce(maybe_e: Expr(Maybe(a)), default: Expr(a)) -> Expr(a)
190
+ Expr(
191
+ "COALESCE(" ++ maybe_e.sql ++ ", " ++ default.sql ++ ")",
192
+ maybe_e.params ++ default.params,
193
+ )
194
+ end
195
+
196
+
197
+ def neg(e: Expr(Int)) -> Expr(Int)
198
+ Expr("-(" ++ e.sql ++ ")", e.params)
199
+ end
200
+
201
+
202
+ # PG array predicates.
203
+ # All bind the array as a single param (`$1`) instead of expanding to
204
+ # `ARRAY[$1,...,$N]`. The pg driver maps Ruby Array → PG array; PG
205
+ # infers the element type from the column on the other side, so empty
206
+ # arrays bind correctly and no `ARRAY[]::t[]` cast is needed.
207
+
208
+
209
+ def encode_list(values: List(a)) -> Value
210
+ Encode.list((x) -> { encode(x) }, values)
211
+ end
212
+
213
+
214
+ def array_overlaps(col: Expr(List(a)), values: List(a)) -> Expr(Bool)
215
+ Expr(col.sql ++ " && ?", col.params ++ [encode_list(values)])
216
+ end
217
+
218
+
219
+ def array_has(col: Expr(List(a)), value: a) -> Expr(Bool)
220
+ Expr("? = ANY(" ++ col.sql ++ ")", [encode(value)] ++ col.params)
221
+ end
222
+
223
+
224
+ def array_contains(col: Expr(List(a)), values: List(a)) -> Expr(Bool)
225
+ Expr(col.sql ++ " @> ?", col.params ++ [encode_list(values)])
226
+ end
227
+
228
+
229
+ def array_contained_by(col: Expr(List(a)), values: List(a)) -> Expr(Bool)
230
+ Expr(col.sql ++ " <@ ?", col.params ++ [encode_list(values)])
231
+ end
232
+
233
+
234
+ def array_length(col: Expr(List(a))) -> Expr(Int)
235
+ Expr("cardinality(" ++ col.sql ++ ")", col.params)
236
+ end
237
+
238
+
239
+ def array_append(col: Expr(List(a)), value: a) -> Expr(List(a))
240
+ Expr(
241
+ "array_append(" ++ col.sql ++ ", ?)",
242
+ col.params ++ [encode(value)],
243
+ )
244
+ end
245
+
246
+
247
+ def array_remove(col: Expr(List(a)), value: a) -> Expr(List(a))
248
+ Expr(
249
+ "array_remove(" ++ col.sql ++ ", ?)",
250
+ col.params ++ [encode(value)],
251
+ )
252
+ end
253
+
254
+
255
+ def array_concat(left: Expr(List(a)), right: Expr(List(a))) -> Expr(List(a))
256
+ Expr(left.sql ++ " || " ++ right.sql, left.params ++ right.params)
257
+ end
258
+
259
+
260
+ # jsonb predicates. `value` is encoded via the caller's `Encodable(a)`
261
+ # instance, so user code can pass a record directly:
262
+ #
263
+ # jsonb_contains(column("rules", "match"), { kind: "income" })
264
+ #
265
+ # pg binds Ruby Hash/Array as jsonb when paired with a jsonb column.
266
+ # The `@?` jsonpath operator needs an explicit `::jsonpath` cast since
267
+ # the param is bound as text.
268
+
269
+
270
+ def jsonb_contains(col: Expr(Value), value: a) -> Expr(Bool)
271
+ Expr(col.sql ++ " @> ?", col.params ++ [encode(value)])
272
+ end
273
+
274
+
275
+ def jsonb_path_exists(col: Expr(Value), path: String) -> Expr(Bool)
276
+ Expr(col.sql ++ " @? ?::jsonpath", col.params ++ [encode(path)])
277
+ end
278
+
279
+
280
+ def in_(col: Expr(a), values: List(a)) -> Expr(Bool)
281
+ case values
282
+ in [] then Expr("FALSE", [])
283
+ else in_nonempty(col, List.map(values, encode))
284
+ end
285
+ end
286
+
287
+
288
+ def in_nonempty(col: Expr(a), encoded: List(Value)) -> Expr(Bool)
289
+ placeholders = encoded
290
+ |> List.map((_) -> { "?" })
291
+ |> String.join(", ")
292
+
293
+ Expr(col.sql ++ " IN (" ++ placeholders ++ ")", col.params ++ encoded)
294
+ end
295
+
296
+
297
+ def table(
298
+ name: String,
299
+ alias_: String,
300
+ cols: String -> c,
301
+ maybe_cols: String -> m,
302
+ pk_columns: List(String),
303
+ ) -> Table(c, m)
304
+ Table(name, alias_, cols, maybe_cols, pk_columns)
305
+ end
306
+
307
+
308
+ def columns(t: Table(c, m), alias_: String) -> c
309
+ alias_ |> t.cols
310
+ end
311
+
312
+
313
+ def maybe_columns(t: Table(c, m), alias_: String) -> m
314
+ alias_ |> t.maybe_cols
315
+ end
316
+
317
+
318
+ def aliased(t: Table(c, m), alias_: String) -> Table(c, m)
319
+ Table(t.name, alias_, t.cols, t.maybe_cols, t.pk_columns)
320
+ end
321
+
322
+
323
+ type SqlError
324
+ = DbError(String)
325
+ | NotFound
326
+ | NotUnique
327
+
328
+
329
+ implements Encodable(SqlError) with
330
+ encoder: encode_sql_error
331
+ end
332
+
333
+
334
+ def encode_sql_error(e: SqlError) -> Value
335
+ case e
336
+ in DbError(msg) then Encode.variant("DbError", [Encode.string(msg)])
337
+ in NotFound then Encode.variant("NotFound", [])
338
+ in NotUnique then Encode.variant("NotUnique", [])
339
+ end
340
+ end
341
+
342
+
343
+ implements Decodable(SqlError) with
344
+ decoder: sql_error_decoder
345
+ end
346
+
347
+
348
+ def sql_error_decoder -> Decoder(SqlError)
349
+ Decode.type_
350
+ |> Decode.variant("DbError", db_error_decoder)
351
+ |> Decode.variant("NotFound", Decode.succeed(NotFound))
352
+ |> Decode.variant("NotUnique", Decode.succeed(NotUnique))
353
+ end
354
+
355
+
356
+ def db_error_decoder -> Decoder(SqlError)
357
+ Decode.index(1, Decode.string) |> Decode.map(DbError)
358
+ end
359
+
360
+
361
+ uses JadeSql::Runtime with
362
+ port_execute_count : (String, List(Value)) -> Task(Int, SqlError),
363
+ port_execute_one : (String, List(Value)) -> Task(a, SqlError),
364
+ port_execute_many : (String, List(Value)) -> Task(List(a), SqlError),
365
+ port_begin : Task(Bool, SqlError),
366
+ port_commit : Task(Bool, SqlError),
367
+ port_rollback : Task(Bool, SqlError)
368
+ end
369
+
370
+
371
+ def execute_raw(p: (String, List(Value))) -> Task(Int, SqlError)
372
+ port_execute_count(p)
373
+ end
374
+
375
+
376
+ def fetch_one_raw(p: (String, List(Value))) -> Task(a, SqlError)
377
+ port_execute_one(p)
378
+ end
379
+
380
+
381
+ def fetch_many_raw(p: (String, List(Value))) -> Task(List(a), SqlError)
382
+ port_execute_many(p)
383
+ end
384
+
385
+
386
+ def execute(r: r) -> Task(Int, SqlError)
387
+ render(r) |> execute_raw
388
+ end
389
+
390
+
391
+ def fetch_one(r: r) -> Task(a, SqlError)
392
+ render(r) |> fetch_one_raw
393
+ end
394
+
395
+
396
+ def fetch_many(r: r) -> Task(List(a), SqlError)
397
+ render(r) |> fetch_many_raw
398
+ end
399
+
400
+
401
+ # Runs `task` inside a single DB transaction on the shared connection:
402
+ # every execute/fetch the task performs participates in it. Commits on
403
+ # Ok, rolls back and re-raises the error on Err. Does not nest — wrapping
404
+ # a transaction in a transaction is unsupported for now (no savepoints).
405
+ def transaction(task: Task(a, SqlError)) -> Task(a, SqlError)
406
+ port_begin()
407
+ |> Task.and_then((_) -> { commit_on_ok(task) })
408
+ end
409
+
410
+
411
+ def commit_on_ok(task: Task(a, SqlError)) -> Task(a, SqlError)
412
+ task
413
+ |> Task.and_then((value) -> { Task.map(port_commit(), (_) -> { value }) })
414
+ |> Task.on_error(rollback_then_fail)
415
+ end
416
+
417
+
418
+ def rollback_then_fail(err: SqlError) -> Task(a, SqlError)
419
+ port_rollback()
420
+ |> Task.and_then((_) -> { Task.fail(err) })
421
+ end
@@ -0,0 +1,19 @@
1
+ require 'jade-sql/bin/generate_schema'
2
+
3
+ namespace :jade do
4
+ desc "Generate schema.jd from db/structure.sql (INPUT, OUTPUT, TABLES, MODULE)"
5
+ task :schema do
6
+ input = ENV['INPUT'] || 'db/structure.sql'
7
+ output = ENV['OUTPUT'] || 'app/jade/schema.jd'
8
+ tables = ENV['TABLES']&.split(',')&.map(&:strip)&.reject(&:empty?)
9
+ module_name = ENV['MODULE'] || 'Schema'
10
+
11
+ FileUtils.mkdir_p(File.dirname(output))
12
+ File.write(
13
+ output,
14
+ JadeSql::SchemaGenerator.generate(File.read(input), tables: tables, module_name: module_name),
15
+ )
16
+
17
+ puts "wrote #{output}"
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ require 'securerandom'
2
+ require 'jade/port'
3
+
4
+ # UUID v4/v7 generation ports for Sql.Uuid. Stdlib-only, no AR — safe
5
+ # to require eagerly from jade-sql.rb (unlike runtime.rb which pulls
6
+ # in ActiveRecord and is opt-in).
7
+ module JadeSql
8
+ module Uuid
9
+ module Runtime
10
+ extend Jade::Port
11
+
12
+ task(:generate_v4) { |t| t.ok({ "value" => ::SecureRandom.uuid }) }
13
+ task(:generate_v7) { |t| t.ok({ "value" => ::SecureRandom.uuid_v7 }) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module JadeSql
2
+ VERSION = '0.1.0'
3
+ end
data/lib/jade-sql.rb ADDED
@@ -0,0 +1,42 @@
1
+ require 'jade'
2
+
3
+ require_relative 'jade-sql/version'
4
+
5
+ Jade.extension(__FILE__)
6
+
7
+ require_relative 'jade-sql/uuid_runtime'
8
+
9
+ # Encoded `Sql.SqlError` values (the `[tag, ...args]` shape produced
10
+ # by Jade's variant encoder). Used by `runtime.rb` to emit errors back
11
+ # across the port boundary, and by anyone stubbing the ports in tests.
12
+ module JadeSql
13
+ module SqlErrors
14
+ NOT_FOUND = ["NotFound"].freeze
15
+ NOT_UNIQUE = ["NotUnique"].freeze
16
+
17
+ def self.db_error(msg) = ["DbError", msg]
18
+ def self.not_found = NOT_FOUND
19
+ def self.not_unique = NOT_UNIQUE
20
+ end
21
+ end
22
+
23
+ module Sql
24
+ module Errors
25
+ class Error < StandardError; end
26
+ class DbError < Error; end
27
+ class NotFound < Error; end
28
+ class NotUnique < Error; end
29
+
30
+ BY_TAG = {
31
+ "DbError" => DbError,
32
+ "NotFound" => NotFound,
33
+ "NotUnique" => NotUnique,
34
+ }.freeze
35
+ end
36
+
37
+ def self.raise_typed!(encoded)
38
+ type, message = encoded
39
+ klass = Errors::BY_TAG.fetch(type, Errors::Error)
40
+ raise klass, message
41
+ end
42
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jade-sql
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - agustin
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-06-14 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jade-lang
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.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.
29
+ email:
30
+ - agustincornu@fastmail.com
31
+ executables:
32
+ - jade-sql
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - docs/building.md
39
+ - docs/running.md
40
+ - exe/jade-sql
41
+ - lib/jade-sql.rb
42
+ - lib/jade-sql/bin/generate_schema.rb
43
+ - lib/jade-sql/runtime.rb
44
+ - lib/jade-sql/sql.jd
45
+ - lib/jade-sql/sql/loader.jd
46
+ - lib/jade-sql/sql/mutation.jd
47
+ - lib/jade-sql/sql/query.jd
48
+ - lib/jade-sql/sql/uuid.jd
49
+ - lib/jade-sql/tasks.rake
50
+ - lib/jade-sql/uuid_runtime.rb
51
+ - lib/jade-sql/version.rb
52
+ homepage: https://github.com/agustinrhcp/jade-sql
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ source_code_uri: https://github.com/agustinrhcp/jade-sql
57
+ rubygems_mfa_required: 'true'
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '3.4'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.6.2
73
+ specification_version: 4
74
+ summary: Type-safe SQL extension for Jade
75
+ test_files: []