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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +68 -0
- data/docs/building.md +491 -0
- data/docs/running.md +103 -0
- data/exe/jade-sql +67 -0
- data/lib/jade-sql/bin/generate_schema.rb +219 -0
- data/lib/jade-sql/runtime.rb +193 -0
- data/lib/jade-sql/sql/loader.jd +30 -0
- data/lib/jade-sql/sql/mutation.jd +268 -0
- data/lib/jade-sql/sql/query.jd +313 -0
- data/lib/jade-sql/sql/uuid.jd +126 -0
- data/lib/jade-sql/sql.jd +421 -0
- data/lib/jade-sql/tasks.rake +19 -0
- data/lib/jade-sql/uuid_runtime.rb +16 -0
- data/lib/jade-sql/version.rb +3 -0
- data/lib/jade-sql.rb +42 -0
- metadata +75 -0
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
|