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.
@@ -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
- RESERVED = %w[
62
- type def module import exposing struct interface implements uses
63
- case in if then else end with
64
- ].freeze
65
-
66
- Table = Data.define(:name, :columns, :pk_columns)
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
- parsed = bodies.map { |name, body| Table[name, parse_columns(body, name), pks[name] || []] }
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
- format(emit(parsed, module_name))
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(_) then text # parse error — return unformatted; downstream compile will surface it
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+(.+?)(\s+NOT\s+NULL)?\s*\z/i)
212
+ m = line.match(/\A"?(\w+)"?\s+(.+)\z/m)
146
213
  raise "Cannot parse column: #{line.inspect}" unless m
147
214
 
148
- name, type_part, not_null = m[1], m[2].strip, !m[3].nil?
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[name, jade_type, !not_null]
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 { |c| c.strip.delete('"') }] }
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| RESERVED.include?(c.name) }
457
+ t.columns.any? { |c| reserved?(c.name) }
180
458
  end
181
459
 
182
460
  def emit_header(tables, module_name)
183
- projectored = tables.select { |t| reserved_cols?(t) }
461
+ keyed = tables.select { |t| keyed?(t) }
184
462
 
185
463
  names = tables
186
- .flat_map { |t| ["#{camel(t.name)}Cols", "Maybe#{camel(t.name)}Cols", "#{camel(t.name)}Row(..)", t.name] }
187
- names += projectored.map { |t| "#{t.name}_row" }
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
- sql_import = projectored.any? ?
191
- "import Sql exposing(Expr, Selector, Table, column, table)" :
192
- "import Sql exposing(Expr, Table, column, table)"
193
- query_import = projectored.any? ? ["import Sql.Query exposing(Q, field_as, select)"] : []
194
- imports = [sql_import, *query_import, *extra_imports_for(tables)]
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 { |c| " #{field_name(c.name)}: Expr(#{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type})" }
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 emit_maybe_cols(t)
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 Maybe#{camel(t.name)}Cols = {\n#{fields}\n}"
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 { |c| " #{field_name(c.name)}: #{c.nullable ? "Maybe(#{c.jade_type})" : c.jade_type}" }
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
- RESERVED.include?(name) ? "#{name}_" : name
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} -> Table(#{camel(t.name)}Cols, Maybe#{camel(t.name)}Cols)
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) -> { Maybe#{camel(t.name)}Cols(#{maybe_fields}) },
253
- #{pk_list},
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) -> Q(Selector(#{klass}Row))
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('_').map(&:capitalize).join
892
+ snake.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map(&:capitalize).join
282
893
  end
283
894
  end
284
895
  end