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.
- checksums.yaml +4 -4
- data/README.md +16 -10
- data/docs/building.md +398 -96
- data/docs/running.md +37 -17
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +655 -44
- data/lib/jade-sql/compiler/assignable.rb +146 -0
- data/lib/jade-sql/compiler/columns.rb +220 -0
- data/lib/jade-sql/compiler/errors.rb +82 -0
- data/lib/jade-sql/compiler/selectable.rb +76 -0
- data/lib/jade-sql/compiler.rb +34 -0
- data/lib/jade-sql/runtime.rb +15 -11
- data/lib/jade-sql/schema_drift.rb +115 -0
- data/lib/jade-sql/sql/expr.jd +65 -0
- data/lib/jade-sql/sql/json.jd +154 -0
- data/lib/jade-sql/sql/query.jd +284 -92
- data/lib/jade-sql/sql/write.jd +625 -0
- data/lib/jade-sql/sql.jd +328 -90
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +8 -7
- metadata +16 -8
- data/lib/jade-sql/sql/mutation.jd +0 -464
|
@@ -46,6 +46,14 @@ module JadeSql
|
|
|
46
46
|
/\Adate\b/ => "Calendar.Date",
|
|
47
47
|
/\Atimestamp\b/ => "Clock.Instant",
|
|
48
48
|
/\Auuid\b/ => "Uuid",
|
|
49
|
+
/\A(?:big|small)?serial\b/ => "Int",
|
|
50
|
+
|
|
51
|
+
# These arrive as their text form on the `exec_query` path the runtime
|
|
52
|
+
# uses, so `String` is what they decode as.
|
|
53
|
+
/\Acitext\b/ => "String",
|
|
54
|
+
/\Ainet\b/ => "String",
|
|
55
|
+
/\Acidr\b/ => "String",
|
|
56
|
+
/\Amacaddr8?\b/ => "String",
|
|
49
57
|
}.freeze
|
|
50
58
|
|
|
51
59
|
EXTRA_IMPORTS = {
|
|
@@ -58,21 +66,52 @@ module JadeSql
|
|
|
58
66
|
|
|
59
67
|
# Column names that collide with Jade keywords get a trailing underscore
|
|
60
68
|
# in the struct field; the SQL column reference keeps the real name.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Column = Data.define(:name, :jade_type, :nullable)
|
|
69
|
+
Table = Data.define(:name, :columns, :pk_columns, :pk_name, :fks, :uniques)
|
|
70
|
+
# `defaulted` is what the database fills in when an INSERT leaves the
|
|
71
|
+
# column out — a DEFAULT clause, an identity or serial sequence. Not the
|
|
72
|
+
# same question as `nullable`: a NOT NULL column with a default is still
|
|
73
|
+
# optional to write.
|
|
74
|
+
Column = Data.define(:name, :jade_type, :nullable, :defaulted, :generated)
|
|
68
75
|
|
|
69
76
|
def generate(sql, tables: nil, columns: nil, module_name: 'Schema')
|
|
77
|
+
@enums = parse_enums(sql).to_h { [it.name, it] }
|
|
70
78
|
bodies = scan_table_bodies(sql)
|
|
71
79
|
bodies = select_tables(bodies, tables) if tables
|
|
72
80
|
pks = parse_pks(sql)
|
|
73
|
-
|
|
81
|
+
fks = parse_fks(sql)
|
|
82
|
+
uniques = parse_uniques(sql)
|
|
83
|
+
defaults = parse_alter_defaults(sql)
|
|
84
|
+
parsed = bodies
|
|
85
|
+
.map { |name, body|
|
|
86
|
+
Table[
|
|
87
|
+
name, parse_columns(body, name),
|
|
88
|
+
pks[name]&.columns || [], pks[name]&.name || '',
|
|
89
|
+
fks[name], uniques[name],
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
.map { |t| t.with(columns: apply_defaults(t.columns, defaults[t.name] || [])) }
|
|
93
|
+
.then { |ts| ts.map { |t| t.with(fks: relations(t, ts)) } }
|
|
74
94
|
parsed = select_columns(parsed, columns) if columns
|
|
75
|
-
|
|
95
|
+
|
|
96
|
+
enum_modules(module_name)
|
|
97
|
+
.merge(module_name => format(emit(parsed, module_name)))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The generator emitted something Jade cannot read. Nothing downstream can
|
|
101
|
+
# do better than say so here: the file would be written, the run would
|
|
102
|
+
# succeed, and the error would surface on the next compile with nothing
|
|
103
|
+
# pointing back at the DDL that caused it.
|
|
104
|
+
class UnparseableSchema < StandardError
|
|
105
|
+
def initialize(text, errors)
|
|
106
|
+
super(<<~MSG)
|
|
107
|
+
The generated schema is not valid Jade. This is a generator bug —
|
|
108
|
+
please report the DDL that produced it.
|
|
109
|
+
|
|
110
|
+
#{Array(errors).map { " #{it.respond_to?(:message) ? it.message : it}" }.join("\n")}
|
|
111
|
+
|
|
112
|
+
#{text.lines.first(40).map { " #{it}" }.join.rstrip}
|
|
113
|
+
MSG
|
|
114
|
+
end
|
|
76
115
|
end
|
|
77
116
|
|
|
78
117
|
# Run jade-fmt over the emitted source so the written schema.jd matches
|
|
@@ -87,13 +126,31 @@ module JadeSql
|
|
|
87
126
|
.then do
|
|
88
127
|
case it
|
|
89
128
|
in ::Jade::Ok(result) then result.end_with?("\n") ? result : "#{result}\n"
|
|
90
|
-
in ::Jade::Err(
|
|
129
|
+
in ::Jade::Err(errors) then raise UnparseableSchema.new(text, errors)
|
|
91
130
|
end
|
|
92
131
|
end
|
|
93
132
|
end
|
|
94
133
|
|
|
134
|
+
# Where a generated module goes, relative to where its root was written.
|
|
135
|
+
# jade reads the module name off the path, so `Schema.InvoiceStatus` has
|
|
136
|
+
# to sit next to `schema.jd` as `schema/invoice_status.jd`.
|
|
137
|
+
def module_path(root_module, module_name, root_path)
|
|
138
|
+
module_name
|
|
139
|
+
.delete_prefix(root_module)
|
|
140
|
+
.split('.')
|
|
141
|
+
.reject(&:empty?)
|
|
142
|
+
.map { snake_case(it) }
|
|
143
|
+
.then { it.empty? ? root_path : "#{File.join(root_path.sub(/\.jd\z/, ''), *it)}.jd" }
|
|
144
|
+
end
|
|
145
|
+
|
|
95
146
|
private
|
|
96
147
|
|
|
148
|
+
def snake_case(name)
|
|
149
|
+
name
|
|
150
|
+
.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
151
|
+
.downcase
|
|
152
|
+
end
|
|
153
|
+
|
|
97
154
|
# Returns [[name, body], ...] without parsing columns, so the whitelist
|
|
98
155
|
# can be applied before type-mapping — an unsupported type in a table the
|
|
99
156
|
# caller didn't ask for shouldn't abort the whole run.
|
|
@@ -141,57 +198,320 @@ module JadeSql
|
|
|
141
198
|
.map { |line| parse_column(line, table_name) }
|
|
142
199
|
end
|
|
143
200
|
|
|
201
|
+
IDENTITY = /\bGENERATED\s+(?:ALWAYS|BY\s+DEFAULT)\s+AS\s+IDENTITY\b/i
|
|
202
|
+
|
|
203
|
+
# Computed from the other columns. Inserting into one is an error rather
|
|
204
|
+
# than redundant, so it can never be required.
|
|
205
|
+
GENERATED_STORED = /\bGENERATED\s+ALWAYS\s+AS\s+\(.*\)\s+STORED\b/im
|
|
206
|
+
SERIAL = /\A(?:big|small)?serial\b/i
|
|
207
|
+
|
|
208
|
+
# Modifiers can come in either order (`NOT NULL DEFAULT 0` and
|
|
209
|
+
# `DEFAULT 0 NOT NULL` are both valid), so each is looked for anywhere in
|
|
210
|
+
# the definition rather than anchored to the end.
|
|
144
211
|
def parse_column(line, table_name)
|
|
145
|
-
m = line.match(/\A"?(\w+)"?\s+(
|
|
212
|
+
m = line.match(/\A"?(\w+)"?\s+(.+)\z/m)
|
|
146
213
|
raise "Cannot parse column: #{line.inspect}" unless m
|
|
147
214
|
|
|
148
|
-
name,
|
|
149
|
-
|
|
150
|
-
# Strip trailing modifiers we don't care about (DEFAULT ..., COLLATE ...).
|
|
151
|
-
type_part = type_part.sub(/\s+DEFAULT\s+.+\z/i, '').sub(/\s+COLLATE\s+.+\z/i, '').strip
|
|
215
|
+
name, rest = m[1], m[2].strip
|
|
216
|
+
type_part = strip_modifiers(rest)
|
|
152
217
|
|
|
153
|
-
jade_type = TYPE_MAP
|
|
154
|
-
.find { |sql_pat, _| sql_pat.match?(type_part.downcase) }
|
|
218
|
+
jade_type = enum_type(type_part) || TYPE_MAP
|
|
219
|
+
.find { |sql_pat, _| sql_pat.match?(unqualified(type_part).downcase) }
|
|
155
220
|
&.last
|
|
156
221
|
|
|
157
222
|
raise "Unknown SQL type for #{table_name}.#{name}: #{type_part.inspect}" unless jade_type
|
|
158
223
|
|
|
159
|
-
Column[
|
|
224
|
+
Column[
|
|
225
|
+
name,
|
|
226
|
+
jade_type,
|
|
227
|
+
!rest.match?(/\bNOT\s+NULL\b/i),
|
|
228
|
+
rest.match?(/\bDEFAULT\b/i) || rest.match?(IDENTITY) || type_part.match?(SERIAL),
|
|
229
|
+
rest.match?(GENERATED_STORED) ? true : false,
|
|
230
|
+
]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def strip_modifiers(rest)
|
|
234
|
+
rest
|
|
235
|
+
.sub(/\s+DEFAULT\s+.+\z/i, '')
|
|
236
|
+
.sub(/\s+GENERATED\s+.+\z/i, '')
|
|
237
|
+
.sub(/\s+COLLATE\s+.+\z/i, '')
|
|
238
|
+
.sub(/\s*\bNOT\s+NULL\b/i, '')
|
|
239
|
+
.then { strip_length(it) }
|
|
240
|
+
.strip
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The array patterns end in `[]` and so tolerate nothing before it, which
|
|
244
|
+
# would type `character varying(255)[]` as its own element.
|
|
245
|
+
def strip_length(type_part)
|
|
246
|
+
type_part.sub(/\((?:\d+(?:\s*,\s*\d+)?)\)/, '')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A Rails structure.sql gives a serial column its default in a separate
|
|
250
|
+
# statement, after the CREATE TABLE:
|
|
251
|
+
#
|
|
252
|
+
# ALTER TABLE ONLY public.patients
|
|
253
|
+
# ALTER COLUMN id SET DEFAULT nextval(...);
|
|
254
|
+
ALTER_DEFAULT = /
|
|
255
|
+
ALTER\ TABLE\s+(?:ONLY\s+)?(?:\w+\.)?"?(\w+)"?\s+
|
|
256
|
+
ALTER\ COLUMN\s+"?(\w+)"?\s+
|
|
257
|
+
(?:SET\ DEFAULT|ADD\ GENERATED\s+(?:ALWAYS|BY\ DEFAULT)\s+AS\ IDENTITY)
|
|
258
|
+
/imx
|
|
259
|
+
|
|
260
|
+
def parse_alter_defaults(sql)
|
|
261
|
+
sql
|
|
262
|
+
.scan(ALTER_DEFAULT)
|
|
263
|
+
.group_by(&:first)
|
|
264
|
+
.transform_values { |pairs| pairs.map(&:last) }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def apply_defaults(columns, defaulted)
|
|
268
|
+
columns.map { it.defaulted ? it : it.with(defaulted: defaulted.include?(it.name)) }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
Enum = Data.define(:name, :labels)
|
|
272
|
+
|
|
273
|
+
# `CREATE TYPE public.visit_status AS ENUM ('scheduled', 'done');`
|
|
274
|
+
def parse_enums(sql)
|
|
275
|
+
sql
|
|
276
|
+
.scan(/CREATE TYPE (?:\w+\.)?(\w+) AS ENUM \(([^)]*)\)/i)
|
|
277
|
+
.map { |name, labels| Enum[name, labels.scan(/'([^']*)'/).flatten] }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
Fk = Data.define(:column, :parent, :parent_column)
|
|
281
|
+
|
|
282
|
+
def parse_fks(sql)
|
|
283
|
+
sql
|
|
284
|
+
.scan(/ALTER TABLE (?:ONLY\s+)?(?:\w+\.)?(\w+)\s+ADD CONSTRAINT \w+ FOREIGN KEY \(([^)]+)\) REFERENCES (?:\w+\.)?(\w+)\(([^)]+)\)/i)
|
|
285
|
+
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(child, col, parent, parent_col), acc|
|
|
286
|
+
acc[child] << Fk[col.strip.delete('"'), parent, parent_col.strip.delete('"')]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
Unique = Data.define(:name, :columns)
|
|
291
|
+
|
|
292
|
+
# Both spellings of the same fact: a table-level constraint, which
|
|
293
|
+
# pg_dump writes as an ALTER, and a standalone unique index. A partial
|
|
294
|
+
# index is skipped, since it constrains only the rows its WHERE matches
|
|
295
|
+
# and a conflict target built from it would not be the one it enforces.
|
|
296
|
+
UNIQUE_CONSTRAINT = /
|
|
297
|
+
ALTER\ TABLE\s+(?:ONLY\s+)?(?:\w+\.)?(\w+)\s+
|
|
298
|
+
ADD\ CONSTRAINT\ (\w+)\ UNIQUE\s+(?:NULLS\ NOT\ DISTINCT\s+)?\(
|
|
299
|
+
/ix
|
|
300
|
+
|
|
301
|
+
UNIQUE_INDEX = /CREATE\ UNIQUE\ INDEX\ (\w+)\ ON\ (?:\w+\.)?(\w+)\ USING\ \w+\s*\(/ix
|
|
302
|
+
|
|
303
|
+
def parse_uniques(sql)
|
|
304
|
+
(unique_constraints(sql) + unique_indexes(sql))
|
|
305
|
+
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(table, name, cols), acc|
|
|
306
|
+
acc[table] << Unique[name, cols]
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def unique_constraints(sql)
|
|
311
|
+
scan_with_columns(sql, UNIQUE_CONSTRAINT) { |m, cols| [m[1], m[2], cols] }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# A partial index constrains only the rows its WHERE matches, so a
|
|
315
|
+
# conflict target built from it would not be the one it enforces.
|
|
316
|
+
def unique_indexes(sql)
|
|
317
|
+
scan_with_columns(sql, UNIQUE_INDEX) do |m, cols, tail|
|
|
318
|
+
next nil if tail[/\A[^;]*/] =~ /\bWHERE\b/i
|
|
319
|
+
|
|
320
|
+
[m[2], m[1], cols]
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The column list runs to the paren that closes it rather than to the
|
|
325
|
+
# first `)`, so an expression like `lower((email)::text)` arrives whole
|
|
326
|
+
# instead of as `lower((email`.
|
|
327
|
+
def scan_with_columns(sql, pattern)
|
|
328
|
+
sql.to_enum(:scan, pattern).filter_map do
|
|
329
|
+
m = Regexp.last_match
|
|
330
|
+
body, rest = balanced(sql, m.end(0))
|
|
331
|
+
next nil unless body
|
|
332
|
+
|
|
333
|
+
cols = split_columns(body).map { plain_column(it) }
|
|
334
|
+
next nil if cols.any?(&:nil?)
|
|
335
|
+
|
|
336
|
+
yield(m, cols, rest)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def balanced(sql, from)
|
|
341
|
+
depth = 1
|
|
342
|
+
i = from
|
|
343
|
+
while i < sql.length
|
|
344
|
+
depth += 1 if sql[i] == '('
|
|
345
|
+
depth -= 1 if sql[i] == ')'
|
|
346
|
+
return [sql[from...i], sql[(i + 1)..]] if depth.zero?
|
|
347
|
+
|
|
348
|
+
i += 1
|
|
349
|
+
end
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def split_columns(body)
|
|
354
|
+
depth = 0
|
|
355
|
+
body.each_char.with_object([+'']) do |c, parts|
|
|
356
|
+
depth += 1 if c == '('
|
|
357
|
+
depth -= 1 if c == ')'
|
|
358
|
+
c == ',' && depth.zero? ? parts << +'' : parts.last << c
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# An index column may carry an operator class, a sort order, a NULLS
|
|
363
|
+
# placement or a collation, and Postgres infers a conflict target by the
|
|
364
|
+
# column underneath all of them. What it cannot reduce to a column is an
|
|
365
|
+
# expression, and `Unique` promises columns a read can bind values to, so
|
|
366
|
+
# those are skipped the way partial indexes are.
|
|
367
|
+
DECORATION = /\s+(?:COLLATE\s+\S+|ASC|DESC|NULLS\s+(?:FIRST|LAST)|\w+_(?:ops|pattern_ops))\b/i
|
|
368
|
+
|
|
369
|
+
def plain_column(part)
|
|
370
|
+
part.strip.gsub(DECORATION, '').strip.delete('"').then do
|
|
371
|
+
it.match?(/\A\w+\z/) ? it : nil
|
|
372
|
+
end
|
|
160
373
|
end
|
|
161
374
|
|
|
375
|
+
Pk = Data.define(:name, :columns)
|
|
376
|
+
|
|
162
377
|
def parse_pks(sql)
|
|
163
378
|
sql
|
|
164
|
-
.scan(/ALTER TABLE (?:ONLY\s+)?(?:\w+\.)?(\w+)\s+ADD CONSTRAINT \w+ PRIMARY KEY \(([^)]+)\)/i)
|
|
165
|
-
.to_h { |name, cols| [name, cols.split(',').map {
|
|
379
|
+
.scan(/ALTER TABLE (?:ONLY\s+)?(?:\w+\.)?(\w+)\s+ADD CONSTRAINT (\w+) PRIMARY KEY \(([^)]+)\)/i)
|
|
380
|
+
.to_h { |table, name, cols| [table, Pk[name, cols.split(',').map { it.strip.delete('"') }]] }
|
|
166
381
|
end
|
|
167
382
|
|
|
168
383
|
def emit(tables, module_name)
|
|
169
384
|
[
|
|
170
385
|
emit_header(tables, module_name),
|
|
171
|
-
*tables.flat_map { |t|
|
|
172
|
-
parts = [emit_strict_cols(t), emit_maybe_cols(t), emit_row(t), emit_table_fn(t)]
|
|
173
|
-
reserved_cols?(t) ? parts + [emit_row_projector(t)] : parts
|
|
174
|
-
},
|
|
386
|
+
*tables.flat_map { |t| emit_table(t) },
|
|
175
387
|
].join("\n\n") + "\n"
|
|
176
388
|
end
|
|
177
389
|
|
|
390
|
+
Rel = Data.define(:name, :other, :own_column, :other_column, :own_null, :other_null)
|
|
391
|
+
|
|
392
|
+
# Both ends of every foreign key, so a join reads the same from either
|
|
393
|
+
# side: `patients.on.phone` and `phones.on.patient` are one constraint.
|
|
394
|
+
# Only relationships whose other end was generated are emitted.
|
|
395
|
+
def relations(t, tables)
|
|
396
|
+
names = tables.map(&:name)
|
|
397
|
+
|
|
398
|
+
by_name = tables.to_h { [it.name, it] }
|
|
399
|
+
null = ->(table, col) { table&.columns&.find { it.name == col }&.nullable }
|
|
400
|
+
|
|
401
|
+
outgoing = t.fks
|
|
402
|
+
.select { names.include?(it.parent) }
|
|
403
|
+
.map do |fk|
|
|
404
|
+
Rel[
|
|
405
|
+
fk.column.sub(/_id\z/, ""), fk.parent, fk.column, fk.parent_column,
|
|
406
|
+
null.(t, fk.column), null.(by_name[fk.parent], fk.parent_column),
|
|
407
|
+
]
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
incoming = tables
|
|
411
|
+
.flat_map { |other| other.fks.map { [other, it] } }
|
|
412
|
+
.select { |(other, fk)| fk.parent == t.name && other.name != t.name }
|
|
413
|
+
.map do |(other, fk)|
|
|
414
|
+
Rel[
|
|
415
|
+
other.name, other.name, fk.parent_column, fk.column,
|
|
416
|
+
null.(t, fk.parent_column), null.(other, fk.column),
|
|
417
|
+
]
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
dedupe(outgoing + incoming)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def dedupe(rels)
|
|
424
|
+
rels.each_with_object([]) do |rel, acc|
|
|
425
|
+
taken = acc.map(&:name)
|
|
426
|
+
next acc << rel unless taken.include?(rel.name)
|
|
427
|
+
|
|
428
|
+
acc << rel.with(name: "#{rel.name}_#{rel.other_column.sub(/_id\z/, '')}")
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def emit_table(t)
|
|
433
|
+
[
|
|
434
|
+
emit_strict_cols(t),
|
|
435
|
+
emit_left_cols(t),
|
|
436
|
+
*emit_required_cols(t),
|
|
437
|
+
emit_set_cols(t),
|
|
438
|
+
emit_set_cols_fn(t),
|
|
439
|
+
emit_table_alias(t),
|
|
440
|
+
emit_row(t),
|
|
441
|
+
*emit_on(t),
|
|
442
|
+
*emit_on_fns(t),
|
|
443
|
+
emit_table_fn(t),
|
|
444
|
+
]
|
|
445
|
+
.then { keyed?(t) ? it + [emit_pk_fn(t), emit_pk_values_fn(t)] : it }
|
|
446
|
+
.then do
|
|
447
|
+
it + all_uniques(t).flat_map { |u| [emit_unique_fn(t, u), emit_unique_values_fn(t, u)] }
|
|
448
|
+
end
|
|
449
|
+
.then { it + [emit_row_projector(t)] }
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def keyed?(t)
|
|
453
|
+
t.pk_columns.any?
|
|
454
|
+
end
|
|
455
|
+
|
|
178
456
|
def reserved_cols?(t)
|
|
179
|
-
t.columns.any? { |c|
|
|
457
|
+
t.columns.any? { |c| reserved?(c.name) }
|
|
180
458
|
end
|
|
181
459
|
|
|
182
460
|
def emit_header(tables, module_name)
|
|
183
|
-
|
|
461
|
+
keyed = tables.select { |t| keyed?(t) }
|
|
184
462
|
|
|
185
463
|
names = tables
|
|
186
|
-
.flat_map
|
|
187
|
-
|
|
464
|
+
.flat_map do |t|
|
|
465
|
+
[
|
|
466
|
+
camel(t.name),
|
|
467
|
+
"#{camel(t.name)}Cols",
|
|
468
|
+
"#{camel(t.name)}LeftCols",
|
|
469
|
+
*("Required#{camel(t.name)}Cols" if required_columns(t).any?),
|
|
470
|
+
"#{camel(t.name)}SetCols",
|
|
471
|
+
"#{camel(t.name)}Row(..)",
|
|
472
|
+
*("#{camel(t.name)}On(..)" if t.fks.any?),
|
|
473
|
+
t.name,
|
|
474
|
+
]
|
|
475
|
+
end
|
|
476
|
+
names += tables.map { |t| "#{t.name}_row" }
|
|
477
|
+
names += tables.flat_map { |t| all_uniques(t).map(&:name) }
|
|
188
478
|
exposed = names.sort.join(", ")
|
|
189
479
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
480
|
+
joined = tables.select { it.fks.any? }
|
|
481
|
+
bare = tables.select { it.fks.empty? }
|
|
482
|
+
unkeyed = keyed.size < tables.size
|
|
483
|
+
|
|
484
|
+
types = [
|
|
485
|
+
"Expr",
|
|
486
|
+
"Col(..)",
|
|
487
|
+
"Table",
|
|
488
|
+
"Selector",
|
|
489
|
+
*("Pk" if keyed.any?),
|
|
490
|
+
*("NoJoins" if bare.any?),
|
|
491
|
+
*("NoKey" if unkeyed),
|
|
492
|
+
*("NoRequiredCols" if tables.any? { required_columns(it).empty? }),
|
|
493
|
+
*("Unique" if tables.any? { all_uniques(it).any? }),
|
|
494
|
+
].sort
|
|
495
|
+
fns = [
|
|
496
|
+
"column",
|
|
497
|
+
"table",
|
|
498
|
+
*("nullable" if joined.any?),
|
|
499
|
+
*("no_joins" if bare.any?),
|
|
500
|
+
*("pk" if keyed.any?),
|
|
501
|
+
*("unkeyed" if unkeyed),
|
|
502
|
+
*("unique" if tables.any? { all_uniques(it).any? }),
|
|
503
|
+
].sort
|
|
504
|
+
sql_import = "import Sql exposing(#{(types + fns).join(', ')})"
|
|
505
|
+
enum_imports = enum_imports_for(tables, module_name)
|
|
506
|
+
query_import = ["import Sql.Query exposing(Select, field_as, select)"]
|
|
507
|
+
# A join predicate compares two columns, which is `Sql.Expr`'s side of
|
|
508
|
+
# the operator split rather than `Sql`'s value-taking one.
|
|
509
|
+
expr_import = joined.any? ? ["import Sql.Expr as Expr"] : []
|
|
510
|
+
encode_import = keyed.any? ? ["import Decode", "import Encode"] : []
|
|
511
|
+
imports = [
|
|
512
|
+
sql_import, *query_import, *expr_import, *encode_import,
|
|
513
|
+
*extra_imports_for(tables), *enum_imports,
|
|
514
|
+
]
|
|
195
515
|
|
|
196
516
|
<<~JADE.strip
|
|
197
517
|
module #{module_name} exposing(#{exposed})
|
|
@@ -200,6 +520,18 @@ module JadeSql
|
|
|
200
520
|
JADE
|
|
201
521
|
end
|
|
202
522
|
|
|
523
|
+
# Aliased to the module's own last segment, so a column reads
|
|
524
|
+
# `Expr(InvoiceStatus.InvoiceStatus)` rather than the whole path.
|
|
525
|
+
def enum_imports_for(tables, module_name)
|
|
526
|
+
tables
|
|
527
|
+
.flat_map { |t| t.columns.map(&:jade_type) }
|
|
528
|
+
.filter_map { it[/\A(\w+)\./, 1] }
|
|
529
|
+
.uniq
|
|
530
|
+
.select { |mod| (@enums || {}).keys.any? { camel(it) == mod } }
|
|
531
|
+
.sort
|
|
532
|
+
.map { "import #{module_name}.#{it} as #{it}" }
|
|
533
|
+
end
|
|
534
|
+
|
|
203
535
|
def extra_imports_for(tables)
|
|
204
536
|
tables
|
|
205
537
|
.flat_map { |t| t.columns.map(&:jade_type) }
|
|
@@ -210,52 +542,257 @@ module JadeSql
|
|
|
210
542
|
.sort
|
|
211
543
|
end
|
|
212
544
|
|
|
545
|
+
# A nullable column reads, writes and decodes as `Maybe`. `LeftCols` is
|
|
546
|
+
# the exception: a left join makes every column nullable, whatever the
|
|
547
|
+
# DDL says.
|
|
548
|
+
def col_type(c)
|
|
549
|
+
c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type
|
|
550
|
+
end
|
|
551
|
+
|
|
213
552
|
def emit_strict_cols(t)
|
|
214
553
|
fields = t.columns
|
|
215
|
-
.map {
|
|
554
|
+
.map { " #{field_name(it.name)}: Expr(#{col_type(it)})" }
|
|
216
555
|
.join(",\n")
|
|
217
556
|
|
|
218
557
|
"struct #{camel(t.name)}Cols = {\n#{fields}\n}"
|
|
219
558
|
end
|
|
220
559
|
|
|
221
|
-
def
|
|
560
|
+
def emit_left_cols(t)
|
|
222
561
|
fields = t.columns
|
|
223
562
|
.map { |c| " #{field_name(c.name)}: Expr(Maybe(#{c.jade_type}))" }
|
|
224
563
|
.join(",\n")
|
|
225
564
|
|
|
226
|
-
"struct
|
|
565
|
+
"struct #{camel(t.name)}LeftCols = {\n#{fields}\n}"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# The columns an insert has to write: NOT NULL, with nothing on the
|
|
569
|
+
# database side to fill them in.
|
|
570
|
+
def required_columns(t)
|
|
571
|
+
t.columns.reject { it.nullable || it.defaulted || it.generated }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def emit_required_cols(t)
|
|
575
|
+
required_columns(t).then do |cols|
|
|
576
|
+
next [] if cols.empty?
|
|
577
|
+
|
|
578
|
+
cols
|
|
579
|
+
.map { " #{field_name(it.name)}: Expr(#{it.jade_type})" }
|
|
580
|
+
.join(",\n")
|
|
581
|
+
.then { ["struct Required#{camel(t.name)}Cols = {\n#{it}\n}"] }
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def required_type(t)
|
|
586
|
+
required_columns(t).any? ? "Required#{camel(t.name)}Cols" : 'NoRequiredCols'
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# The table's own type, with every argument filled in. Nothing else can
|
|
590
|
+
# shorten `Table(c, m, k, o, r, s)`: an alias has to bind every variable its
|
|
591
|
+
# body names, so only a fully applied one saves anything.
|
|
592
|
+
# The left of a `SET` is a column name, not an expression, so the fields
|
|
593
|
+
# are `Col` rather than `Expr` and nothing recovers a name from rendered
|
|
594
|
+
# SQL.
|
|
595
|
+
def emit_set_cols(t)
|
|
596
|
+
t.columns
|
|
597
|
+
.map { " #{field_name(it.name)}: Col(#{col_type(it)})" }
|
|
598
|
+
.join(",\n")
|
|
599
|
+
.then { "struct #{camel(t.name)}SetCols = {\n#{it}\n}" }
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def emit_set_cols_fn(t)
|
|
603
|
+
t.columns
|
|
604
|
+
.map { " Col(#{it.name.inspect})" }
|
|
605
|
+
.join(",\n")
|
|
606
|
+
.then do
|
|
607
|
+
"def #{t.name}_set_cols -> #{camel(t.name)}SetCols\n" \
|
|
608
|
+
" #{camel(t.name)}SetCols(\n#{it},\n )\nend"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def emit_table_alias(t)
|
|
613
|
+
[
|
|
614
|
+
"#{camel(t.name)}Cols",
|
|
615
|
+
"#{camel(t.name)}LeftCols",
|
|
616
|
+
key_type(t),
|
|
617
|
+
on_type(t),
|
|
618
|
+
required_type(t),
|
|
619
|
+
"#{camel(t.name)}SetCols",
|
|
620
|
+
].join(', ').then { "type alias #{camel(t.name)} = Table(#{it})" }
|
|
227
621
|
end
|
|
228
622
|
|
|
229
623
|
def emit_row(t)
|
|
230
624
|
fields = t.columns
|
|
231
|
-
.map {
|
|
625
|
+
.map { " #{field_name(it.name)}: #{col_type(it)}" }
|
|
232
626
|
.join(",\n")
|
|
233
627
|
|
|
234
628
|
"struct #{camel(t.name)}Row = {\n#{fields}\n}"
|
|
235
629
|
end
|
|
236
630
|
|
|
631
|
+
# A column whose name is a jade keyword cannot be a field, so it gains a
|
|
632
|
+
# trailing underscore. `JadeSql::Compiler.column_name` is the inverse, and
|
|
633
|
+
# both read the lexer rather than a list of their own.
|
|
634
|
+
def reserved?(name) = Jade::Lexer::KEYWORDS.include?(name)
|
|
635
|
+
|
|
237
636
|
def field_name(name)
|
|
238
|
-
|
|
637
|
+
reserved?(name) ? "#{name}_" : name
|
|
239
638
|
end
|
|
240
639
|
|
|
241
640
|
def emit_table_fn(t)
|
|
242
641
|
strict_fields = t.columns.map { |c| "column(a, #{c.name.inspect})" }.join(", ")
|
|
243
642
|
maybe_fields = t.columns.map { |c| "column(a, #{c.name.inspect})" }.join(", ")
|
|
244
|
-
pk_list = "[#{t.pk_columns.map(&:inspect).join(", ")}]"
|
|
245
643
|
|
|
246
644
|
<<~JADE.strip
|
|
247
|
-
def #{t.name} ->
|
|
645
|
+
def #{t.name} -> #{camel(t.name)}
|
|
248
646
|
table(
|
|
249
647
|
#{t.name.inspect},
|
|
250
648
|
#{t.name.inspect},
|
|
251
649
|
(a) -> { #{camel(t.name)}Cols(#{strict_fields}) },
|
|
252
|
-
(a) -> {
|
|
253
|
-
#{
|
|
650
|
+
(a) -> { #{camel(t.name)}LeftCols(#{maybe_fields}) },
|
|
651
|
+
#{t.name}_set_cols,
|
|
652
|
+
#{keyed?(t) ? "#{t.name}_pk" : "unkeyed"},
|
|
653
|
+
#{emit_on_value(t)},
|
|
254
654
|
)
|
|
255
655
|
end
|
|
256
656
|
JADE
|
|
257
657
|
end
|
|
258
658
|
|
|
659
|
+
def key_columns(t)
|
|
660
|
+
t.columns.select { t.pk_columns.include?(it.name) }
|
|
661
|
+
.sort_by { t.pk_columns.index(it.name) }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def key_type(t)
|
|
665
|
+
return "NoKey" unless keyed?(t)
|
|
666
|
+
|
|
667
|
+
key_columns(t)
|
|
668
|
+
.map(&:jade_type)
|
|
669
|
+
.then { it.one? ? it.first : "(#{it.join(', ')})" }
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def emit_on(t)
|
|
673
|
+
return nil if t.fks.empty?
|
|
674
|
+
|
|
675
|
+
fields = t.fks
|
|
676
|
+
.map { " #{field_name(it.name)}: #{camel(t.name)}Cols -> (#{camel(it.other)}Cols -> Expr(Bool))" }
|
|
677
|
+
.join(",\n")
|
|
678
|
+
|
|
679
|
+
"struct #{camel(t.name)}On = {\n#{fields}\n}"
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def on_type(t)
|
|
683
|
+
t.fks.empty? ? "NoJoins" : "#{camel(t.name)}On"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def emit_on_value(t)
|
|
687
|
+
return "no_joins" if t.fks.empty?
|
|
688
|
+
|
|
689
|
+
t.fks
|
|
690
|
+
.map { on_fn_name(t, it) }
|
|
691
|
+
.join(", ")
|
|
692
|
+
.then { "#{camel(t.name)}On(#{it})" }
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# A nullable foreign key column is `Expr(Maybe(a))` while the key it
|
|
696
|
+
# points at is `Expr(a)`, so whichever side is not nullable is lifted.
|
|
697
|
+
def side(var, column, mine, theirs)
|
|
698
|
+
ref = "#{var}.#{field_name(column)}"
|
|
699
|
+
|
|
700
|
+
!mine && theirs ? "#{ref} |> nullable" : ref
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# A relation is named after its foreign key with the `_id` dropped, so
|
|
704
|
+
# `import_id` gives `import` — a name a Jade field cannot have. The
|
|
705
|
+
# trailing underscore is the same one a reserved column gets.
|
|
706
|
+
def on_fn_name(t, rel)
|
|
707
|
+
"#{t.name}_on_#{field_name(rel.name)}"
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Named rather than inlined in the constructor for the same reason the key
|
|
711
|
+
# spread is: inference does not reach a lambda passed to a constructor, so
|
|
712
|
+
# `a.phone_id` there infers an open record instead of the column struct.
|
|
713
|
+
def emit_on_fns(t)
|
|
714
|
+
t.fks.map do |rel|
|
|
715
|
+
<<~JADE.strip
|
|
716
|
+
def #{on_fn_name(t, rel)}(a: #{camel(t.name)}Cols) -> #{camel(rel.other)}Cols -> Expr(Bool)
|
|
717
|
+
(b) -> {
|
|
718
|
+
Expr.eq(#{side("a", rel.own_column, rel.own_null, rel.other_null)}, #{side("b", rel.other_column, rel.other_null, rel.own_null)})
|
|
719
|
+
}
|
|
720
|
+
end
|
|
721
|
+
JADE
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# The index name is what Postgres reports in a violation, so naming it
|
|
726
|
+
# here is what lets a caller route the error without matching a string.
|
|
727
|
+
def emit_unique_fn(t, u)
|
|
728
|
+
cols = u.columns.map { it.inspect }.join(", ")
|
|
729
|
+
|
|
730
|
+
<<~JADE.strip
|
|
731
|
+
def #{u.name} -> Unique(#{camel(t.name)}Cols, #{unique_key_type(t, u)})
|
|
732
|
+
unique(#{u.name.inspect}, [#{cols}], #{u.name}_values)
|
|
733
|
+
end
|
|
734
|
+
JADE
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def emit_unique_values_fn(t, u)
|
|
738
|
+
names = u.columns.each_index.map { |i| "v#{i}" }
|
|
739
|
+
encoded = names.map { "Encode.encode(#{it})" }.join(", ")
|
|
740
|
+
|
|
741
|
+
body = names.one? ?
|
|
742
|
+
" [Encode.encode(v)]" :
|
|
743
|
+
" (#{names.join(', ')}) = v\n\n [#{encoded}]"
|
|
744
|
+
|
|
745
|
+
<<~JADE.strip
|
|
746
|
+
def #{u.name}_values(v: #{unique_key_type(t, u)}) -> List(Decode.Value)
|
|
747
|
+
#{body}
|
|
748
|
+
end
|
|
749
|
+
JADE
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def unique_key_type(t, u)
|
|
753
|
+
u.columns
|
|
754
|
+
.map { |name| t.columns.find { |c| c.name == name } }
|
|
755
|
+
.map { |c| c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type }
|
|
756
|
+
.then { it.one? ? it.first : "(#{it.join(', ')})" }
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# The primary key is a unique index, and `ON CONFLICT` arbitrates on it the
|
|
760
|
+
# way it does on any other. Postgres names it in the DDL, so it is emitted
|
|
761
|
+
# under that name rather than under one of ours.
|
|
762
|
+
def all_uniques(t)
|
|
763
|
+
keyed?(t) ? [Unique[t.pk_name, t.pk_columns]] + t.uniques : t.uniques
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def emit_pk_fn(t)
|
|
767
|
+
cols = key_columns(t).map { it.name.inspect }.join(", ")
|
|
768
|
+
|
|
769
|
+
<<~JADE.strip
|
|
770
|
+
def #{t.name}_pk -> Pk(#{camel(t.name)}Cols, #{key_type(t)})
|
|
771
|
+
pk(#{t.pk_name.inspect}, [#{cols}], #{t.name}_pk_values)
|
|
772
|
+
end
|
|
773
|
+
JADE
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# A composite key arrives as a tuple and has to spread across its columns
|
|
777
|
+
# in the order the DDL declares them, never the order a caller guesses.
|
|
778
|
+
# Named rather than a lambda so its parameter carries an annotation:
|
|
779
|
+
# inference does not reach a lambda passed to `Pk`, and destructuring a
|
|
780
|
+
# value of unknown type is a non-exhaustive match.
|
|
781
|
+
def emit_pk_values_fn(t)
|
|
782
|
+
names = key_columns(t).each_index.map { |i| "v#{i}" }
|
|
783
|
+
encoded = names.map { "Encode.encode(#{it})" }.join(", ")
|
|
784
|
+
|
|
785
|
+
body = names.one? ?
|
|
786
|
+
" [Encode.encode(v)]" :
|
|
787
|
+
" (#{names.join(', ')}) = v\n\n [#{encoded}]"
|
|
788
|
+
|
|
789
|
+
<<~JADE.strip
|
|
790
|
+
def #{t.name}_pk_values(v: #{key_type(t)}) -> List(Decode.Value)
|
|
791
|
+
#{body}
|
|
792
|
+
end
|
|
793
|
+
JADE
|
|
794
|
+
end
|
|
795
|
+
|
|
259
796
|
# A row projector that aliases every column to its (possibly renamed)
|
|
260
797
|
# field name, so a reserved-word column like `type` round-trips through
|
|
261
798
|
# decode: `SELECT alias.type AS type_`. Emitted only for tables that have
|
|
@@ -270,15 +807,89 @@ module JadeSql
|
|
|
270
807
|
.join("\n")
|
|
271
808
|
|
|
272
809
|
<<~JADE.strip
|
|
273
|
-
def #{t.name}_row(c: #{klass}Cols) ->
|
|
810
|
+
def #{t.name}_row(c: #{klass}Cols) -> Select(#{klass}Row)
|
|
274
811
|
select(#{klass}Row(#{holes}))
|
|
275
812
|
#{projections}
|
|
276
813
|
end
|
|
277
814
|
JADE
|
|
278
815
|
end
|
|
279
816
|
|
|
817
|
+
# A column typed by a CREATE TYPE enum, with or without its schema prefix.
|
|
818
|
+
# An extension type carries the schema that owns it: `public.citext`.
|
|
819
|
+
def unqualified(type_part)
|
|
820
|
+
type_part.sub(/\A\w+\./, "")
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# Qualified, since the enum lives in a module of its own.
|
|
824
|
+
def enum_type(type_part)
|
|
825
|
+
type_part
|
|
826
|
+
.sub(/\A\w+\./, "")
|
|
827
|
+
.then { @enums&.key?(it) ? "#{camel(it)}.#{enum_type_name(it)}" : nil }
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# A postgres enum belongs to the schema, not to a table — two tables can
|
|
831
|
+
# share one, and its labels are bare constructors, so two enums with a
|
|
832
|
+
# `pending` label cannot sit in one module. Each gets its own, named after
|
|
833
|
+
# the SQL type, and so does the type inside it: `InvoiceStatus.InvoiceStatus`
|
|
834
|
+
# is a mouthful, but every shorter name is a guess at where the SQL name
|
|
835
|
+
# divides. `jade.json` is where that gets said.
|
|
836
|
+
def enum_modules(module_name)
|
|
837
|
+
(@enums || {}).values.to_h do |e|
|
|
838
|
+
["#{module_name}.#{camel(e.name)}", format(emit_enum_module(module_name, e))]
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def emit_enum_module(module_name, enum)
|
|
843
|
+
enum_type_name(enum.name).then do |type_name|
|
|
844
|
+
<<~JADE
|
|
845
|
+
module #{module_name}.#{camel(enum.name)} exposing (#{type_name}(..))
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
type #{type_name}
|
|
849
|
+
= #{variants_of(enum).join("\n | ")}
|
|
850
|
+
JADE
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def enum_type_name(sql_name)
|
|
855
|
+
camel(sql_name)
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def variants_of(e)
|
|
859
|
+
e.labels
|
|
860
|
+
.map { variant(e, it) }
|
|
861
|
+
.then { |variants| collision(e, variants) || variants }
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# A Postgres label is any text, a Jade constructor is not. Two labels that
|
|
865
|
+
# differ only where the punctuation was — 'not started' and 'not_started' —
|
|
866
|
+
# camel to one name, which Jade would report against the generated file
|
|
867
|
+
# rather than against the DDL that holds both.
|
|
868
|
+
def collision(e, variants)
|
|
869
|
+
variants
|
|
870
|
+
.tally
|
|
871
|
+
.select { |_, n| n > 1 }
|
|
872
|
+
.then do |dupes|
|
|
873
|
+
next nil if dupes.empty?
|
|
874
|
+
|
|
875
|
+
raise "Enum #{e.name} has labels that name one constructor: " \
|
|
876
|
+
"#{dupes.keys.join(', ')}"
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def variant(e, label)
|
|
881
|
+
camel(label).then do |name|
|
|
882
|
+
next name if name.match?(/\A[A-Z][A-Za-z0-9]*\z/)
|
|
883
|
+
|
|
884
|
+
raise "Enum #{e.name} has a label that cannot be a Jade constructor: " \
|
|
885
|
+
"#{label.inspect}"
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Splits on punctuation as well as underscores, so a label carrying a space
|
|
890
|
+
# or a dash still names a constructor.
|
|
280
891
|
def camel(snake)
|
|
281
|
-
snake.split(
|
|
892
|
+
snake.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map(&:capitalize).join
|
|
282
893
|
end
|
|
283
894
|
end
|
|
284
895
|
end
|