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.
data/exe/jade-sql ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
+
5
+ require 'optparse'
6
+ require 'jade-sql/version'
7
+
8
+ def run_schema(args)
9
+ require 'fileutils'
10
+ require 'jade-sql'
11
+ require 'jade-sql/bin/generate_schema'
12
+
13
+ opts = {
14
+ input: 'db/structure.sql',
15
+ output: 'app/jade/schema.jd',
16
+ module_name: 'Schema',
17
+ tables: nil,
18
+ }
19
+
20
+ OptionParser.new do |o|
21
+ o.banner = 'Usage: jade-sql schema [options]'
22
+ o.on('-i', '--input PATH', 'source DDL (default: db/structure.sql)') { opts[:input] = it }
23
+ o.on('-o', '--output PATH', 'destination .jd (default: app/jade/schema.jd)') { opts[:output] = it }
24
+ o.on('-t', '--tables a,b,c', 'comma-separated table whitelist') {
25
+ opts[:tables] = it.split(',').map(&:strip).reject(&:empty?)
26
+ }
27
+ o.on('-m', '--module NAME', 'module name (default: Schema)') { opts[:module_name] = it }
28
+ end.parse!(args)
29
+
30
+ generated = JadeSql::SchemaGenerator.generate(
31
+ File.read(opts[:input]),
32
+ tables: opts[:tables],
33
+ module_name: opts[:module_name],
34
+ )
35
+
36
+ FileUtils.mkdir_p(File.dirname(opts[:output]))
37
+ File.write(opts[:output], generated)
38
+ puts "wrote #{opts[:output]}"
39
+ end
40
+
41
+ HELP = <<~USAGE
42
+ jade-sql #{JadeSql::VERSION} — type-safe SQL for Jade
43
+
44
+ Usage: jade-sql <command> [options]
45
+
46
+ Commands:
47
+ schema Generate schema.jd from a SQL structure dump
48
+ version Print the version
49
+ help Show this message
50
+
51
+ Run `jade-sql schema --help` for schema options.
52
+ USAGE
53
+
54
+ case (command = ARGV.shift)
55
+ in 'schema'
56
+ run_schema(ARGV)
57
+
58
+ in 'version' | '-v' | '--version'
59
+ puts JadeSql::VERSION
60
+
61
+ in nil | 'help' | '-h' | '--help'
62
+ puts HELP
63
+
64
+ else
65
+ warn "jade-sql: unknown command '#{command}' (try `jade-sql help`)"
66
+ exit 1
67
+ end
@@ -0,0 +1,219 @@
1
+ require 'jade/lexer'
2
+ require 'jade/parsing'
3
+ require 'jade/ast'
4
+ require 'jade/formatter'
5
+ require 'jade/frontend/comment_attacher'
6
+
7
+ module JadeSql
8
+ module SchemaGenerator
9
+ extend self
10
+
11
+ # Array variants are listed first because `\b` would otherwise match
12
+ # the scalar prefix of e.g. `text[]` and map it to "String".
13
+ TYPE_MAP = {
14
+ /\Abigint\[\]/ => "List(Int)",
15
+ /\Ainteger\[\]/ => "List(Int)",
16
+ /\Asmallint\[\]/ => "List(Int)",
17
+ /\Acharacter varying\[\]/ => "List(String)",
18
+ /\Avarchar\[\]/ => "List(String)",
19
+ /\Atext\[\]/ => "List(String)",
20
+ /\Aboolean\[\]/ => "List(Bool)",
21
+ /\Abool\[\]/ => "List(Bool)",
22
+ /\Ajsonb?\[\]/ => "List(Decode.Value)",
23
+ /\Adate\[\]/ => "List(Calendar.Date)",
24
+ /\Atimestamp\[\]/ => "List(Clock.Instant)",
25
+ /\Auuid\[\]/ => "List(Uuid)",
26
+
27
+ /\Abigint\b/ => "Int",
28
+ /\Ainteger\b/ => "Int",
29
+ /\Asmallint\b/ => "Int",
30
+ /\Acharacter varying\b/ => "String",
31
+ /\Avarchar\b/ => "String",
32
+ /\Acharacter\b/ => "String",
33
+ /\Achar\b/ => "String",
34
+ /\Atext\b/ => "String",
35
+ /\Aboolean\b/ => "Bool",
36
+ /\Abool\b/ => "Bool",
37
+ /\Ajsonb?\b/ => "Decode.Value",
38
+ /\Adate\b/ => "Calendar.Date",
39
+ /\Atimestamp\b/ => "Clock.Instant",
40
+ /\Auuid\b/ => "Uuid",
41
+ }.freeze
42
+
43
+ EXTRA_IMPORTS = {
44
+ "Calendar.Date" => "import Calendar",
45
+ "Clock.Instant" => "import Clock",
46
+ "Decode.Value" => "import Decode",
47
+ "Uuid" => "import Sql.Uuid exposing(Uuid)",
48
+ }.freeze
49
+
50
+ Table = Data.define(:name, :columns, :pk_columns)
51
+ Column = Data.define(:name, :jade_type, :nullable)
52
+
53
+ def generate(sql, tables: nil, module_name: 'Schema')
54
+ parsed = parse_tables(sql)
55
+ pks = parse_pks(sql)
56
+ parsed = parsed.map { |t| t.with(pk_columns: pks[t.name] || []) }
57
+ parsed = filter_tables(parsed, tables) if tables
58
+ format(emit(parsed, module_name))
59
+ end
60
+
61
+ # Run jade-fmt over the emitted source so the written schema.jd matches
62
+ # what the formatter would produce — keeps the generator output stable
63
+ # across formatter improvements and avoids spurious diffs when users
64
+ # re-format their tree.
65
+ def format(text)
66
+ source = ::Jade::Source.new(uri: '<schema>', text: text)
67
+ ::Jade::Lexer.tokenize(source)
68
+ .then { ::Jade::Parsing.parse(it, source:) }
69
+ .map { |(ast, comments)| ::Jade::Formatter.format(ast, comments:, source:) }
70
+ .then do
71
+ case it
72
+ in ::Jade::Ok(result) then result.end_with?("\n") ? result : "#{result}\n"
73
+ in ::Jade::Err(_) then text # parse error — return unformatted; downstream compile will surface it
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
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) }
85
+ end
86
+
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), []] }
91
+ end
92
+
93
+ def parse_columns(body, table_name)
94
+ body
95
+ .split("\n")
96
+ .map(&:strip)
97
+ .reject(&:empty?)
98
+ .map { |line| line.sub(/,\s*\z/, '') }
99
+ .reject { |line| line =~ /\A(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY)\b/i }
100
+ .map { |line| parse_column(line, table_name) }
101
+ end
102
+
103
+ def parse_column(line, table_name)
104
+ m = line.match(/\A"?(\w+)"?\s+(.+?)(\s+NOT\s+NULL)?\s*\z/i)
105
+ raise "Cannot parse column: #{line.inspect}" unless m
106
+
107
+ name, type_part, not_null = m[1], m[2].strip, !m[3].nil?
108
+
109
+ # Strip trailing modifiers we don't care about (DEFAULT ..., COLLATE ...).
110
+ type_part = type_part.sub(/\s+DEFAULT\s+.+\z/i, '').sub(/\s+COLLATE\s+.+\z/i, '').strip
111
+
112
+ jade_type = TYPE_MAP
113
+ .find { |sql_pat, _| sql_pat.match?(type_part.downcase) }
114
+ &.last
115
+
116
+ raise "Unknown SQL type for #{table_name}.#{name}: #{type_part.inspect}" unless jade_type
117
+
118
+ Column[name, jade_type, !not_null]
119
+ end
120
+
121
+ def parse_pks(sql)
122
+ sql
123
+ .scan(/ALTER TABLE (?:ONLY\s+)?(?:\w+\.)?(\w+)\s+ADD CONSTRAINT \w+ PRIMARY KEY \(([^)]+)\)/i)
124
+ .to_h { |name, cols| [name, cols.split(',').map { |c| c.strip.delete('"') }] }
125
+ end
126
+
127
+ def emit(tables, module_name)
128
+ [
129
+ 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)] },
131
+ ].join("\n\n") + "\n"
132
+ end
133
+
134
+ def emit_header(tables, module_name)
135
+ exposed = tables
136
+ .flat_map { |t| ["#{camel(t.name)}Cols", "Maybe#{camel(t.name)}Cols", "#{camel(t.name)}Row(..)", t.name] }
137
+ .sort
138
+ .join(", ")
139
+
140
+ imports = ["import Sql exposing(Expr, Table, column, table)", *extra_imports_for(tables)]
141
+
142
+ <<~JADE.strip
143
+ module #{module_name} exposing(#{exposed})
144
+
145
+ #{imports.join("\n")}
146
+ JADE
147
+ end
148
+
149
+ def extra_imports_for(tables)
150
+ tables
151
+ .flat_map { |t| t.columns.map(&:jade_type) }
152
+ .flat_map { |t| [t, t[/\AList\((.*)\)\z/, 1]].compact }
153
+ .uniq
154
+ .filter_map { |jade_type| EXTRA_IMPORTS[jade_type] }
155
+ .uniq
156
+ .sort
157
+ end
158
+
159
+ def emit_strict_cols(t)
160
+ fields = t.columns
161
+ .map { |c| " #{c.name}: Expr(#{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type})" }
162
+ .join(",\n")
163
+
164
+ "struct #{camel(t.name)}Cols = {\n#{fields}\n}"
165
+ end
166
+
167
+ def emit_maybe_cols(t)
168
+ fields = t.columns
169
+ .map { |c| " #{c.name}: Expr(Maybe(#{c.jade_type}))" }
170
+ .join(",\n")
171
+
172
+ "struct Maybe#{camel(t.name)}Cols = {\n#{fields}\n}"
173
+ end
174
+
175
+ def emit_row(t)
176
+ fields = t.columns
177
+ .map { |c| " #{c.name}: #{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type}" }
178
+ .join(",\n")
179
+
180
+ "struct #{camel(t.name)}Row = {\n#{fields}\n}"
181
+ end
182
+
183
+ def emit_table_fn(t)
184
+ strict_fields = t.columns.map { |c| "column(a, #{c.name.inspect})" }.join(", ")
185
+ maybe_fields = t.columns.map { |c| "column(a, #{c.name.inspect})" }.join(", ")
186
+ pk_list = "[#{t.pk_columns.map(&:inspect).join(", ")}]"
187
+
188
+ <<~JADE.strip
189
+ def #{t.name} -> Table(#{camel(t.name)}Cols, Maybe#{camel(t.name)}Cols)
190
+ table(
191
+ #{t.name.inspect},
192
+ #{t.name.inspect},
193
+ (a) -> { #{camel(t.name)}Cols(#{strict_fields}) },
194
+ (a) -> { Maybe#{camel(t.name)}Cols(#{maybe_fields}) },
195
+ #{pk_list},
196
+ )
197
+ end
198
+ JADE
199
+ end
200
+
201
+ def camel(snake)
202
+ snake.split('_').map(&:capitalize).join
203
+ end
204
+ end
205
+ end
206
+
207
+ if __FILE__ == $0
208
+ if ARGV.empty?
209
+ warn "Usage: ruby #{$0} <schema.sql>"
210
+ warn " TABLES=a,b whitelist of tables (default: all)"
211
+ warn " MODULE=Name override module name (default: Schema)"
212
+ exit 1
213
+ end
214
+
215
+ tables = ENV['TABLES']&.split(',')&.map(&:strip)&.reject(&:empty?)
216
+ module_name = ENV['MODULE'] || 'Schema'
217
+
218
+ puts JadeSql::SchemaGenerator.generate(File.read(ARGV[0]), tables: tables, module_name: module_name)
219
+ end
@@ -0,0 +1,193 @@
1
+ require 'date'
2
+ require 'jade/tasks'
3
+
4
+ # Opt-in runtime: requires ActiveRecord. The Jade-side Sql.Run module
5
+ # declares ports against this. Loaded only when the user explicitly does
6
+ # `require 'jade-sql/runtime'`.
7
+ module JadeSql
8
+ module Runtime
9
+ extend Jade::Port
10
+
11
+ task :port_execute_count do |t, pair|
12
+ sql, params = pair._1, pair._2
13
+ conn = ::ActiveRecord::Base.connection
14
+ t.ok(conn.exec_update(adapt_sql(sql, conn), "Jade", typed_params(params, conn)))
15
+ rescue ::ActiveRecord::StatementInvalid => e
16
+ t.err(JadeSql::SqlErrors.db_error(e.message))
17
+ end
18
+
19
+ task :port_execute_one do |t, pair|
20
+ sql, params = pair._1, pair._2
21
+ conn = ::ActiveRecord::Base.connection
22
+ rows = conn.exec_query(adapt_sql(sql, conn), "Jade", typed_params(params, conn)).to_a
23
+ case rows.length
24
+ when 0 then t.err(JadeSql::SqlErrors.not_found)
25
+ when 1 then t.ok(coerce_row(rows.first))
26
+ else t.err(JadeSql::SqlErrors.not_unique)
27
+ end
28
+ rescue ::ActiveRecord::StatementInvalid => e
29
+ t.err(JadeSql::SqlErrors.db_error(e.message))
30
+ end
31
+
32
+ task :port_execute_many do |t, pair|
33
+ sql, params = pair._1, pair._2
34
+ conn = ::ActiveRecord::Base.connection
35
+ rows = conn.exec_query(adapt_sql(sql, conn), "Jade", typed_params(params, conn)).to_a
36
+ t.ok(rows.map { |row| coerce_row(row) })
37
+ rescue ::ActiveRecord::StatementInvalid => e
38
+ t.err(JadeSql::SqlErrors.db_error(e.message))
39
+ end
40
+
41
+ # Transaction control on the shared connection. The execute/fetch ports
42
+ # above use the same `ActiveRecord::Base.connection`, so anything they
43
+ # run between begin and commit/rollback is part of this transaction.
44
+ # These bypass AR's transaction manager (no savepoints), so they don't
45
+ # nest — see `Sql.transaction`. Rollback is best-effort: it swallows
46
+ # adapter errors so the original failure is the one that propagates.
47
+ task :port_begin do |t|
48
+ ::ActiveRecord::Base.connection.begin_db_transaction
49
+ t.ok(true)
50
+ rescue ::ActiveRecord::StatementInvalid => e
51
+ t.err(JadeSql::SqlErrors.db_error(e.message))
52
+ end
53
+
54
+ task :port_commit do |t|
55
+ ::ActiveRecord::Base.connection.commit_db_transaction
56
+ t.ok(true)
57
+ rescue ::ActiveRecord::StatementInvalid => e
58
+ t.err(JadeSql::SqlErrors.db_error(e.message))
59
+ end
60
+
61
+ task :port_rollback do |t|
62
+ ::ActiveRecord::Base.connection.rollback_db_transaction
63
+ t.ok(true)
64
+ rescue ::ActiveRecord::StatementInvalid
65
+ t.ok(true)
66
+ end
67
+
68
+ # AR's PG adapter returns ::Date / ::Time for date/timestamp columns;
69
+ # Calendar.Date / Clock.Instant decoders expect ISO strings. Coerce
70
+ # at the boundary so callers don't sprinkle text_cast in every SELECT.
71
+ #
72
+ # text[] / int[] / etc. arrive as Postgres array literals (`{a,b,c}`)
73
+ # when AR's exec_query path doesn't run the OID typecast. Parse them
74
+ # back to Ruby Arrays so `Decode.list(...)` works the same as for any
75
+ # other List(a) column.
76
+ def self.coerce_row(row)
77
+ row.transform_values { |v| coerce_value(v) }
78
+ end
79
+
80
+ def self.coerce_value(v)
81
+ case v
82
+ when ::Date then v.iso8601
83
+ when ::Time, ::DateTime then v.iso8601
84
+ when ::String
85
+ pg_array_literal?(v) ? parse_pg_array(v) : v
86
+ else v
87
+ end
88
+ end
89
+
90
+ # PG arrays render as `{}`, `{a,b,c}`, `{"a,b","c"}`, with NULL as
91
+ # bare `NULL`. Quoted elements escape `"` and `\` with backslashes.
92
+ # The JSON-object guard rejects `{"key":...}` shapes — they share the
93
+ # outer braces but should reach Decode.Value as plain strings (or as
94
+ # Hash if AR already typecast the column).
95
+ PG_ARRAY_LITERAL = /\A\{.*\}\z/m
96
+ JSON_OBJECT_HEAD = /\A\{\s*"[^"]*"\s*:/m
97
+
98
+ def self.pg_array_literal?(s)
99
+ s.match?(PG_ARRAY_LITERAL) && !s.match?(JSON_OBJECT_HEAD)
100
+ end
101
+
102
+ def self.parse_pg_array(s)
103
+ inner = s[1..-2]
104
+ return [] if inner.empty?
105
+
106
+ elements = []
107
+ buffer = String.new
108
+ in_quotes = false
109
+ i = 0
110
+ while i < inner.length
111
+ c = inner[i]
112
+ if in_quotes
113
+ if c == '\\' && i + 1 < inner.length
114
+ buffer << inner[i + 1]
115
+ i += 2
116
+ next
117
+ elsif c == '"'
118
+ in_quotes = false
119
+ else
120
+ buffer << c
121
+ end
122
+ else
123
+ if c == '"'
124
+ in_quotes = true
125
+ elsif c == ','
126
+ elements << decode_element(buffer)
127
+ buffer = String.new
128
+ else
129
+ buffer << c
130
+ end
131
+ end
132
+ i += 1
133
+ end
134
+ elements << decode_element(buffer)
135
+ elements
136
+ end
137
+
138
+ def self.decode_element(raw)
139
+ raw == "NULL" ? nil : raw
140
+ end
141
+
142
+ # Sql renders `?` placeholders uniformly. AR's exec_query/exec_update
143
+ # path on the PG adapter expects `$1, $2, …` — there is no `?`-to-`$n`
144
+ # rewrite at that layer. SQLite and MySQL accept `?` directly, so this
145
+ # is a no-op there.
146
+ #
147
+ # The alternation matches a whole quoted span first (single-quoted
148
+ # string with `''` escapes, or double-quoted identifier with `""`
149
+ # escapes), so a literal `?` inside one is left alone — only bare `?`
150
+ # outside quotes becomes a placeholder. Dollar-quoted bodies aren't
151
+ # handled (uncommon in app SQL).
152
+ QUOTED_OR_PLACEHOLDER = /'(?:[^']|'')*'|"(?:[^"]|"")*"|\?/
153
+
154
+ def self.adapt_sql(sql, conn)
155
+ return sql unless conn.adapter_name =~ /postgres/i
156
+
157
+ n = 0
158
+ sql.gsub(QUOTED_OR_PLACEHOLDER) { |m| m == "?" ? "$#{n += 1}" : m }
159
+ end
160
+
161
+ # AR's exec_query raw path can't bind a Ruby Array — pg's OID type
162
+ # cast isn't applied to bare values. Wrap arrays in QueryAttribute
163
+ # with a PG OID::Array so the binding picks the right wire format.
164
+ # Element type sniffs the first non-nil entry; falls back to text.
165
+ def self.typed_params(params, conn)
166
+ return params unless conn.adapter_name =~ /postgres/i
167
+
168
+ params.map { |p| typed_param(p) }
169
+ end
170
+
171
+ def self.typed_param(value)
172
+ case value
173
+ when ::Array
174
+ ::ActiveRecord::Relation::QueryAttribute.new(nil, value, array_type_for(value))
175
+ else
176
+ value
177
+ end
178
+ end
179
+
180
+ def self.array_type_for(elements)
181
+ sample = elements.find { |e| !e.nil? }
182
+ element_type =
183
+ case sample
184
+ when ::Integer then ::ActiveRecord::Type::Integer.new
185
+ when ::Float then ::ActiveRecord::Type::Float.new
186
+ when true, false then ::ActiveRecord::Type::Boolean.new
187
+ else ::ActiveRecord::Type::String.new
188
+ end
189
+
190
+ ::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(element_type)
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,30 @@
1
+ module Sql.Loader exposing (
2
+ group_by,
3
+ lookup_or_empty,
4
+ )
5
+
6
+ import Dict exposing (Dict)
7
+
8
+
9
+ def group_by(items: List(a), key: a -> k) -> Dict(k, List(a))
10
+ List.fold(
11
+ items,
12
+ Dict.empty,
13
+ (acc, item) -> {
14
+ k = key(item)
15
+
16
+ case Dict.get(acc, k)
17
+ in Just(existing) then Dict.insert(acc, k, existing ++ [item])
18
+ in Nothing then Dict.insert(acc, k, [item])
19
+ end
20
+ },
21
+ )
22
+ end
23
+
24
+
25
+ def lookup_or_empty(grouped: Dict(k, List(a)), key: k) -> List(a)
26
+ case Dict.get(grouped, key)
27
+ in Just(items) then items
28
+ in Nothing then []
29
+ end
30
+ end